chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
"use client"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { CheckCircle2Icon, ChevronDownIcon, CopyIcon, CornerDownRightIcon, FlagIcon, PencilIcon, ThumbsUpIcon, Trash2Icon } from "lucide-react"
import { ContentEditor } from "@/components/content-editor"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { PostArticleContent } from "@/app/(support)/support/_components/post-article-content"
import { ensureSupportLogin, communityPostHref } from "@/app/(support)/support/_components/support-community-route"
import { useI18n } from "@/i18n/provider"
import { acceptComment, createComment, deleteComment, fetchComments, reportComment, toggleReaction, updateComment, type Comment, type Post } from "@/lib/api/support-community"
import { cn, formatDateTime } from "@/lib/utils"
import type { ContentValue } from "@/components/content-editor"
export function CommentItem({ comment, post, currentUserId, onChanged }: { comment: Comment; post: Post; currentUserId: number; onChanged: () => void }) {
const t = useI18n()
const authorName = comment.authorName || t("supportPublic.common.user")
const [replying, setReplying] = useState(false)
const [editing, setEditing] = useState(false)
const [replyContent, setReplyContent] = useState<ContentValue>({ mode: "html", raw: "" })
const [editContent, setEditContent] = useState<ContentValue>({ mode: comment.contentType === "markdown" ? "markdown" : "html", raw: comment.content })
const [submitting, setSubmitting] = useState(false)
const [replies, setReplies] = useState(comment.replies || [])
const [repliesExpanded, setRepliesExpanded] = useState((comment.replies || []).length >= comment.replyCount)
const isDeleted = comment.status === "deleted"
const isAuthor = !isDeleted && currentUserId > 0 && comment.authorId === currentUserId
const canAccept = !isDeleted && currentUserId > 0 && currentUserId === post.userId && !comment.isAccepted && comment.parentId === 0
const isPostAuthor = comment.authorId === post.userId
const isReply = comment.parentId > 0
useEffect(() => {
setReplies(comment.replies || [])
setRepliesExpanded((comment.replies || []).length >= comment.replyCount)
}, [comment.id, comment.replyCount, comment.replies])
const submitReply = async () => {
if (submitting || !replyContent.raw.trim()) return
setSubmitting(true)
try {
await ensureSupportLogin()
await createComment({ postId: post.id, parentId: comment.id, contentType: replyContent.mode, content: replyContent.raw })
setReplyContent({ mode: "html", raw: "" })
setReplying(false)
toast.success(t("supportPublic.toast.replyCreated"))
onChanged()
} finally {
setSubmitting(false)
}
}
const submitEdit = async () => {
if (submitting || !editContent.raw.trim()) return
setSubmitting(true)
try {
await updateComment({ id: comment.id, contentType: editContent.mode, content: editContent.raw })
setEditing(false)
toast.success(t("supportPublic.toast.commentUpdated"))
onChanged()
} finally {
setSubmitting(false)
}
}
const deleteCurrentComment = async () => {
if (!window.confirm(t("supportPublic.comment.deleteConfirm"))) return
await deleteComment(comment.id)
toast.success(t("supportPublic.toast.commentDeleted"))
onChanged()
}
const reportCurrentComment = async () => {
await ensureSupportLogin()
await reportComment(comment.id)
toast.success(t("supportPublic.toast.commentReported"))
}
const copyLink = async () => {
const url = `${window.location.origin}${communityPostHref(post.id)}#comment-${comment.id}`
await navigator.clipboard.writeText(url)
toast.success(t("supportPublic.toast.linkCopied"))
}
const loadReplies = async () => {
const result = await fetchComments({ postId: post.id, parentId: comment.id, page: 1, limit: 50 })
setReplies(result.results)
setRepliesExpanded(true)
}
const actionButtonClass = "h-7 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
return (
<article
id={`comment-${comment.id}`}
className={cn(
"group/comment scroll-mt-24 py-4",
isReply && "py-2.5",
comment.isAccepted && !isDeleted && !isReply && "border-l-2 border-emerald-500 pl-3"
)}
>
<div className={cn("flex", isReply ? "gap-2.5" : "gap-3")}>
<div className={cn(
"flex shrink-0 items-center justify-center rounded-full font-semibold",
isReply ? "size-6 bg-muted text-xs text-muted-foreground" : "size-9 bg-primary text-sm text-primary-foreground"
)}>
{supportAuthorInitial(authorName)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-2">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className={cn("truncate font-medium", isReply ? "text-sm text-foreground/90" : "text-sm text-foreground")}>{authorName}</span>
{isPostAuthor ? <span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary">{t("supportPublic.comment.authorBadge")}</span> : null}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{formatDateTime(comment.createdAt)}</div>
</div>
{!isDeleted && comment.isAccepted && !isReply ? <Badge className="rounded-md bg-emerald-600 text-white shadow-none"><CheckCircle2Icon /> {t("supportPublic.comment.accepted")}</Badge> : null}
</div>
<div className={cn("mt-3", isReply && "mt-2 text-sm")}>
{isDeleted ? (
<div className="rounded-md bg-muted/70 px-3 py-2 text-sm text-muted-foreground">{t("supportPublic.comment.deleted")}</div>
) : editing ? (
<div className="rounded-md bg-muted/40 p-3">
<ContentEditor value={editContent} onChange={setEditContent} disabled={submitting} allowedModes={["html", "markdown"]} height={220} className="min-w-0" />
<div className="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" disabled={submitting} onClick={() => setEditing(false)}>{t("supportPublic.actions.cancel")}</Button>
<Button size="sm" disabled={submitting || !editContent.raw.trim()} onClick={() => void submitEdit()}>{t("supportPublic.actions.save")}</Button>
</div>
</div>
) : (
<PostArticleContent id={`support-comment-content-${comment.id}`} content={comment.content} contentType={comment.contentType} articleHeadingIds={false} />
)}
</div>
{!isDeleted ? (
<div className={cn("flex flex-wrap items-center gap-1", isReply ? "mt-3" : "mt-4")}>
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => void ensureSupportLogin().then(() => toggleReaction({ targetType: "comment", targetId: comment.id })).then(onChanged)}>
<ThumbsUpIcon /> {comment.reactionCount}
</Button>
{!isReply ? (
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => setReplying((current) => !current)}>
<CornerDownRightIcon /> {t("supportPublic.actions.reply")}
</Button>
) : null}
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => void copyLink()}>
<CopyIcon /> {t("supportPublic.actions.copyLink")}
</Button>
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => void reportCurrentComment()}>
<FlagIcon /> {t("supportPublic.actions.report")}
</Button>
{isAuthor ? (
<>
<Button variant="ghost" size="sm" className={actionButtonClass} onClick={() => setEditing(true)}>
<PencilIcon /> {t("supportPublic.actions.edit")}
</Button>
<Button variant="ghost" size="sm" className="h-7 rounded-md px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => void deleteCurrentComment()}>
<Trash2Icon /> {t("supportPublic.actions.delete")}
</Button>
</>
) : null}
{canAccept ? (
<Button variant="secondary" size="sm" className="h-7 rounded-md px-2.5 text-xs text-primary" onClick={() => void ensureSupportLogin().then(() => acceptComment(post.id, comment.id)).then(onChanged)}>
{t("supportPublic.actions.accept")}
</Button>
) : null}
</div>
) : null}
{replying ? (
<div className="mt-3 rounded-md bg-muted/40 p-3">
<ContentEditor value={replyContent} onChange={setReplyContent} placeholder={t("supportPublic.comment.replyPlaceholder")} disabled={submitting} allowedModes={["html", "markdown"]} height={180} className="min-w-0" />
<div className="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" disabled={submitting} onClick={() => setReplying(false)}>{t("supportPublic.actions.cancel")}</Button>
<Button size="sm" disabled={submitting || !replyContent.raw.trim()} onClick={() => void submitReply()}>{t("supportPublic.actions.publishReply")}</Button>
</div>
</div>
) : null}
{replies.length ? (
<div className="mt-3 divide-y divide-border/60 border-l border-border/70 pl-3 sm:pl-4">
{replies.map((reply) => (
<CommentItem key={reply.id} comment={reply} post={post} currentUserId={currentUserId} onChanged={onChanged} />
))}
</div>
) : null}
{!repliesExpanded && comment.replyCount > replies.length ? (
<Button variant="ghost" size="sm" className="mt-2 h-7 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" onClick={() => void loadReplies()}>
<ChevronDownIcon /> {t("supportPublic.actions.viewReplies", { count: comment.replyCount })}
</Button>
) : null}
</div>
</div>
</article>
)
}
function supportAuthorInitial(name: string) {
return name.trim().slice(0, 1).toUpperCase() || "U"
}

View File

@@ -0,0 +1,218 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { usePathname, useSearchParams } from "next/navigation"
import { ChevronDownIcon, EyeIcon, LoaderCircleIcon, MessageCircleMoreIcon, ThumbsUpIcon } from "lucide-react"
import { toast } from "sonner"
import { ContentEditor } from "@/components/content-editor"
import { Button } from "@/components/ui/button"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { PublicArticleToc, hasArticleTocHeadings } from "@/app/(support)/support/_components/support-article-toc"
import { CommunityFrame } from "@/app/(support)/support/_components/community-frame"
import { PostArticleContent } from "@/app/(support)/support/_components/post-article-content"
import { ensureSupportLogin, useCommunityCategoryRoute } from "@/app/(support)/support/_components/support-community-route"
import { SupportEmptyState as EmptyState, PostStatusBadge as PostStatusBadge } from "@/app/(support)/support/_components/support-ui"
import { CommentItem } from "@/app/(support)/support/community/posts/detail/_components/comment-item"
import { PostMetric } from "@/app/(support)/support/community/posts/_components/post-ui"
import { useI18n } from "@/i18n/provider"
import { createComment, fetchComments, fetchPost, postsHref, toggleReaction, type Comment, type Post } from "@/lib/api/support-community"
import { readSession } from "@/lib/auth"
import { formatDateTime, cn } from "@/lib/utils"
import type { ContentValue } from "@/components/content-editor"
type CommentSort = "default" | "latest" | "hot"
export function PostDetail() {
const t = useI18n()
const pathname = usePathname()
const searchParams = useSearchParams()
const categoryRoute = useCommunityCategoryRoute()
const postId = useMemo(() => {
const queryId = Number(searchParams.get("id"))
if (queryId > 0) {
return queryId
}
const pathId = Number(pathname.split("/").filter(Boolean).at(-1))
return pathId > 0 ? pathId : 0
}, [pathname, searchParams])
const [post, setPost] = useState<Post | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [content, setContent] = useState<ContentValue>({ mode: "html", raw: "" })
const [commentSort, setCommentSort] = useState<CommentSort>("default")
const [commentPage, setCommentPage] = useState({ page: 1, limit: 20, total: 0 })
const [commentsLoading, setCommentsLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const postArticleId = post ? `support-post-${post.id}` : ""
const postToc = post && hasArticleTocHeadings(post.content, post.contentType)
? <PublicArticleToc articleId={postArticleId} content={post.content} contentType={post.contentType} />
: null
const currentUserId = readSession()?.user.id ?? 0
const loadComments = useCallback((page = 1, append = false) => {
if (postId > 0) {
setCommentsLoading(true)
void fetchComments({ postId, sort: commentSort, page, limit: commentPage.limit })
.then((result) => {
setComments((current) => append ? [...current, ...result.results] : result.results)
setCommentPage(result.page)
})
.finally(() => setCommentsLoading(false))
}
}, [commentPage.limit, commentSort, postId])
const reload = useCallback(() => {
if (postId <= 0) return
void Promise.all([
fetchPost(postId),
fetchComments({ postId, sort: commentSort, page: 1, limit: commentPage.limit }),
]).then(([detail, commentResult]) => {
setPost(detail.post)
setComments(commentResult.results)
setCommentPage(commentResult.page)
})
}, [commentPage.limit, commentSort, postId])
useEffect(() => {
if (postId <= 0) return
void fetchPost(postId).then((detail) => {
setPost(detail.post)
})
}, [postId])
useEffect(() => {
loadComments(1)
}, [loadComments])
const submitComment = async () => {
if (!post || submitting) return
setSubmitting(true)
try {
await ensureSupportLogin()
await createComment({ postId: post.id, contentType: content.mode, content: content.raw })
setContent({ mode: "html", raw: "" })
toast.success(t("supportPublic.toast.commentCreated"))
reload()
} finally {
setSubmitting(false)
}
}
const hasMoreComments = comments.length < commentPage.total
return (
<CommunityFrame active={post?.categoryId ?? "all"} categoryRoute={categoryRoute} toc={postToc}>
<div className="px-4 py-7 sm:px-6 sm:py-10 lg:px-8 2xl:px-10">
{post ? (
<article className="w-full max-w-6xl">
<Breadcrumb className="text-xs">
<BreadcrumbList className="gap-y-1">
<BreadcrumbItem>
<Link href={postsHref()} className="transition-colors hover:text-foreground">{t("supportPublic.posts.title")}</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{post.categoryName || t("supportPublic.common.uncategorized")}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<header className="mt-6">
<h1 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">{post.title}</h1>
</header>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<PostStatusBadge status={post.status} />
<span>{post.categoryName || t("supportPublic.common.uncategorized")}</span>
<span className="text-muted-foreground/40">/</span>
<span>{t("supportPublic.posts.createdBy", { name: post.userName || t("supportPublic.common.user") })}</span>
<span className="text-muted-foreground/40">/</span>
<span>{t("supportPublic.posts.updatedAt", { date: formatDateTime(post.updatedAt || post.createdAt) })}</span>
</div>
<div className="mt-8">
<PostArticleContent id={postArticleId} content={post.content} contentType={post.contentType} />
</div>
<div className="mt-8 flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" className="rounded-md" onClick={() => void ensureSupportLogin().then(() => toggleReaction({ targetType: "post", targetId: post.id })).then(reload)}>
<ThumbsUpIcon /> {post.reactionCount}
</Button>
<PostMetric icon={<MessageCircleMoreIcon className="size-3.5" />} value={post.commentCount} label={t("supportPublic.posts.comments")} />
<PostMetric icon={<EyeIcon className="size-3.5" />} value={post.viewCount} label={t("supportPublic.posts.views")} />
</div>
<section className="mt-8 border-t border-border/70 pt-6" aria-label={t("supportPublic.posts.comments")}>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">{t("supportPublic.posts.comments")}</h2>
<div className="mt-1 text-sm text-muted-foreground">{t("supportPublic.comment.count", { count: commentPage.total || post.commentCount })}</div>
</div>
<div className="inline-flex w-fit gap-1 rounded-md bg-muted p-0.5">
{(["default", "latest", "hot"] as const).map((sort) => (
<button
key={sort}
type="button"
className={cn(
"h-7 rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
commentSort === sort ? "bg-background text-foreground shadow-xs" : "text-muted-foreground hover:text-foreground"
)}
aria-pressed={commentSort === sort}
onClick={() => setCommentSort(sort)}
>
{t(`supportPublic.comment.sort.${sort}`)}
</button>
))}
</div>
</div>
{comments.length ? (
<div className="divide-y divide-border/70">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} post={post} currentUserId={currentUserId} onChanged={reload} />
))}
</div>
) : commentsLoading ? (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
{t("supportPublic.loading.comments")}
</div>
) : <EmptyState text={t("supportPublic.empty.noComments")} />}
{hasMoreComments ? (
<div className="mt-4 flex justify-center">
<Button variant="secondary" size="sm" className="rounded-md" disabled={commentsLoading} onClick={() => loadComments(commentPage.page + 1, true)}>
{commentsLoading ? <LoaderCircleIcon className="animate-spin" /> : <ChevronDownIcon />}
{commentsLoading ? t("supportPublic.loading.comments") : t("supportPublic.actions.loadMore")}
</Button>
</div>
) : null}
</section>
<section className="mt-6 border-t border-border/70 pt-5" aria-labelledby="support-comment-editor-title">
<h2 id="support-comment-editor-title" className="text-base font-semibold">{t("supportPublic.comment.title")}</h2>
<div className="mt-3 min-w-0">
<ContentEditor
value={content}
onChange={setContent}
placeholder={t("supportPublic.comment.placeholder")}
disabled={submitting}
allowedModes={["html", "markdown"]}
height={260}
className="min-w-0"
/>
</div>
<div className="mt-3 flex justify-end">
<Button disabled={submitting || !content.raw.trim()} onClick={() => void submitComment()}>
{submitting ? t("supportPublic.actions.publishing") : t("supportPublic.actions.publishComment")}
</Button>
</div>
</section>
</article>
) : (
<div className="grid min-h-[60svh] place-items-center">
<EmptyState text={t("supportPublic.loading.post")} />
</div>
)}
</div>
</CommunityFrame>
)
}

View File

@@ -0,0 +1,5 @@
import { PostDetail } from "./_components/post-detail"
export default function PostDetailShellPage() {
return <PostDetail />
}