chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
5
web/app/(support)/support/community/categories/page.tsx
Normal file
5
web/app/(support)/support/community/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PostList } from "../posts/_components/post-list"
|
||||
|
||||
export default function SupportCommunityCategoryShellPage() {
|
||||
return <PostList />
|
||||
}
|
||||
5
web/app/(support)/support/community/page.tsx
Normal file
5
web/app/(support)/support/community/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PostList } from "./posts/_components/post-list"
|
||||
|
||||
export default function SupportCommunityPage() {
|
||||
return <PostList />
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { CommunityFrame } from "@/app/(support)/support/_components/community-frame"
|
||||
import { CommunityPostList } from "@/app/(support)/support/_components/community-post-list"
|
||||
import { SupportEmptyState as EmptyState } from "@/app/(support)/support/_components/support-ui"
|
||||
import { newPostHref } from "@/lib/api/support-community"
|
||||
import { useCommunityCategoryRoute } from "@/app/(support)/support/_components/support-community-route"
|
||||
import { PostListLoading } from "@/app/(support)/support/community/posts/_components/post-ui"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function PostList() {
|
||||
const t = useI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const categoryRoute = useCommunityCategoryRoute()
|
||||
const title = searchParams.get("title") || ""
|
||||
const [status, setStatus] = useState(searchParams.get("status") || "all")
|
||||
|
||||
const resetKey = [
|
||||
categoryRoute.activeCategoryId,
|
||||
categoryRoute.categorySlug,
|
||||
status,
|
||||
title,
|
||||
].join(":")
|
||||
const query = {
|
||||
categoryId: categoryRoute.activeCategoryId === "all" ? undefined : categoryRoute.activeCategoryId,
|
||||
status: status === "all" ? undefined : status,
|
||||
title,
|
||||
}
|
||||
const statusOptions = [
|
||||
{ value: "all", label: t("supportPublic.status.all") },
|
||||
{ value: "normal", label: t("supportPublic.status.normal") },
|
||||
{ value: "resolved", label: t("supportPublic.status.resolved") },
|
||||
]
|
||||
const categoryUnavailable = Boolean(categoryRoute.categorySlug && !categoryRoute.categoriesLoading && !categoryRoute.activeCategory)
|
||||
|
||||
return (
|
||||
<CommunityFrame categoryRoute={categoryRoute}>
|
||||
<div className="px-4 pt-3 sm:px-5 md:px-6 lg:px-8 2xl:px-10">
|
||||
<div className="flex flex-col gap-2 border-b pb-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex min-w-0 gap-1.5 overflow-x-auto pb-1 sm:pb-0">
|
||||
{statusOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 whitespace-nowrap rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
status === option.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
aria-pressed={status === option.value}
|
||||
onClick={() => setStatus(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Link className={cn(buttonVariants(), "h-8 px-3 text-xs")} href={newPostHref()}>{t("supportPublic.actions.createPost")}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 sm:px-5 md:px-6 lg:px-8 2xl:px-10">
|
||||
{categoryRoute.categoriesLoading && categoryRoute.categorySlug ? <PostListLoading compact /> : null}
|
||||
{categoryUnavailable ? <EmptyState text={t("supportPublic.empty.noPostsMatched")} /> : null}
|
||||
{!categoryRoute.categoriesLoading && !categoryUnavailable ? (
|
||||
<CommunityPostList
|
||||
resetKey={resetKey}
|
||||
emptyText={t("supportPublic.empty.noPostsMatched")}
|
||||
query={query}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CommunityFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { EyeIcon, MessageCircleMoreIcon, ThumbsUpIcon } from "lucide-react"
|
||||
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { postHref, type Post } from "@/lib/api/support-community"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
|
||||
export function PostCard({ item, compact = false }: { item: Post; compact?: boolean }) {
|
||||
const t = useI18n()
|
||||
return (
|
||||
<article className="border-b border-border/70 last:border-b-0">
|
||||
<div
|
||||
className={cn(
|
||||
"px-1",
|
||||
compact ? "py-2" : "py-3"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* <PostStatusPill status={item.status} /> */}
|
||||
<h2 className="min-w-0 flex-1 truncate text-[15px] font-semibold leading-6">
|
||||
<Link
|
||||
href={postHref(item.id)}
|
||||
className="truncate transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h2>
|
||||
{item.acceptedCommentId > 0 ? <span className="hidden h-5 shrink-0 items-center rounded bg-primary/10 px-1.5 text-[11px] font-medium text-primary sm:inline-flex">{t("supportPublic.comment.accepted")}</span> : null}
|
||||
</div>
|
||||
|
||||
{compact ? null : <p className="mt-1 line-clamp-1 text-sm leading-5 text-muted-foreground">{postExcerpt(item.content)}</p>}
|
||||
|
||||
<div className={cn("flex min-w-0 flex-wrap items-center gap-y-1 text-xs text-muted-foreground", compact ? "mt-1 gap-x-1.5" : "mt-2 gap-x-2")}>
|
||||
<span className="max-w-[10rem] truncate sm:max-w-[14rem]">{item.categoryName || t("supportPublic.common.uncategorized")}</span>
|
||||
<MetaSeparator />
|
||||
<span className="max-w-[10rem] truncate">{t("supportPublic.posts.createdBy", { name: item.userName || t("supportPublic.common.user") })}</span>
|
||||
<MetaSeparator />
|
||||
<span>{t("supportPublic.posts.updatedAt", { date: formatDateTime(item.updatedAt || item.createdAt) })}</span>
|
||||
<span className="hidden flex-1 sm:block" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PostMetric compact={compact} icon={<MessageCircleMoreIcon className="size-3.5" />} value={item.commentCount} label={t("supportPublic.posts.comments")} />
|
||||
<PostMetric compact={compact} icon={<ThumbsUpIcon className="size-3.5" />} value={item.reactionCount} label={t("supportPublic.posts.likes")} />
|
||||
<PostMetric compact={compact} className="hidden sm:inline-flex" icon={<EyeIcon className="size-3.5" />} value={item.viewCount} label={t("supportPublic.posts.views")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostMetric({ icon, value, label, className, compact = false }: { icon: ReactNode; value: number; label: string; className?: string; compact?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md bg-muted text-muted-foreground",
|
||||
compact ? "h-6 px-2 text-xs" : "h-7 px-2.5 text-xs",
|
||||
className
|
||||
)}
|
||||
title={label}
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
{icon}
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaSeparator() {
|
||||
return <span className="text-muted-foreground/35">/</span>
|
||||
}
|
||||
|
||||
export function PostStatusPill({ status }: { status: string }) {
|
||||
const t = useI18n()
|
||||
if (status === "resolved") return <span className="inline-flex h-5 items-center rounded bg-emerald-50 px-1.5 text-[11px] font-medium text-emerald-700">{t("supportPublic.status.resolved")}</span>
|
||||
if (status === "closed") return <span className="inline-flex h-5 items-center rounded bg-muted px-1.5 text-[11px] font-medium text-muted-foreground">{t("supportPublic.status.closed")}</span>
|
||||
return <span className="inline-flex h-5 items-center rounded bg-amber-50 px-1.5 text-[11px] font-medium text-amber-700">{t("supportPublic.status.normal")}</span>
|
||||
}
|
||||
|
||||
export function PostListLoading({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="divide-y divide-border/70" aria-hidden="true">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className={cn("px-1", compact ? "py-2" : "py-3")}>
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
|
||||
<div className={cn("h-5 w-4/5 animate-pulse rounded bg-muted", compact ? "mt-2" : "mt-3")} />
|
||||
<div className={cn("h-4 w-2/3 animate-pulse rounded bg-muted", compact ? "mt-1.5" : "mt-2")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function postExcerpt(content: string) {
|
||||
return content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PostDetail } from "./_components/post-detail"
|
||||
|
||||
export default function PostDetailShellPage() {
|
||||
return <PostDetail />
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { ContentEditor } from "@/components/content-editor"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell"
|
||||
import { useSupportAuth } from "@/app/(support)/support/_components/support-auth-provider"
|
||||
import { ensureSupportLogin } from "@/app/(support)/support/_components/support-community-route"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import { createPost, fetchCategories, postHref, type Category } from "@/lib/api/support-community"
|
||||
import type { ContentValue } from "@/components/content-editor"
|
||||
|
||||
export function CreatePost() {
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const { ready, session } = useSupportAuth()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [categoriesFailed, setCategoriesFailed] = useState(false)
|
||||
const [categoryId, setCategoryId] = useState(0)
|
||||
const [title, setTitle] = useState("")
|
||||
const [content, setContent] = useState<ContentValue>({ mode: "html", raw: "" })
|
||||
const [tags, setTags] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && !session) {
|
||||
router.replace("/support/login?next=%2Fsupport%2Fcommunity%2Fposts%2Fnew")
|
||||
}
|
||||
}, [ready, router, session])
|
||||
|
||||
const loadCategories = useCallback(() => {
|
||||
setCategoriesLoading(true)
|
||||
setCategoriesFailed(false)
|
||||
void fetchCategories()
|
||||
.then((items) => {
|
||||
setCategories(items)
|
||||
setCategoryId((current) => current || items[0]?.id || 0)
|
||||
})
|
||||
.catch(() => {
|
||||
setCategories([])
|
||||
setCategoriesFailed(true)
|
||||
})
|
||||
.finally(() => setCategoriesLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(loadCategories, [loadCategories])
|
||||
|
||||
const handleContentChange = useCallback((next: ContentValue) => {
|
||||
if (content.mode === next.mode && content.raw === next.raw) {
|
||||
return
|
||||
}
|
||||
setContent(next)
|
||||
setFormError("")
|
||||
}, [content.mode, content.raw])
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return
|
||||
if (!categoryId) {
|
||||
setFormError(t("supportPublic.createPost.categoryRequired"))
|
||||
return
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setFormError(t("supportPublic.createPost.titleRequired"))
|
||||
return
|
||||
}
|
||||
if (!content.raw.trim()) {
|
||||
setFormError(t("supportPublic.createPost.contentRequired"))
|
||||
return
|
||||
}
|
||||
setFormError("")
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await ensureSupportLogin()
|
||||
const post = await createPost({
|
||||
categoryId,
|
||||
title,
|
||||
contentType: content.mode,
|
||||
content: content.raw,
|
||||
tags: tags.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
})
|
||||
toast.success(t("supportPublic.toast.postCreated"))
|
||||
window.location.assign(postHref(post.id))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("api.requestFailed")
|
||||
setFormError(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready || !session) {
|
||||
return (
|
||||
<SupportPageShell section="community">
|
||||
<SupportPageContent className="py-10 sm:py-12" width="docs">
|
||||
<div className="flex min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
{t("supportPublic.loading.session")}
|
||||
</div>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SupportPageShell section="community">
|
||||
<SupportPageContent className="py-8 sm:py-10" width="docs">
|
||||
<form
|
||||
className="w-full rounded-md bg-card p-4 sm:p-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 border-b pb-3">
|
||||
<h1 className="text-lg font-medium">{t("supportPublic.createPost.formTitle")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<fieldset aria-label={t("supportPublic.createPost.category")}>
|
||||
<legend className="sr-only">{t("supportPublic.createPost.category")}</legend>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={categoryId === item.id ? "default" : "secondary"}
|
||||
className="rounded-md"
|
||||
disabled={categoriesLoading || submitting}
|
||||
aria-pressed={categoryId === item.id}
|
||||
onClick={() => { setCategoryId(item.id); setFormError("") }}
|
||||
>
|
||||
{item.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{categoriesLoading ? <p className="mt-2 text-xs text-muted-foreground">{t("supportPublic.loading.categories")}</p> : null}
|
||||
{categoriesFailed ? <button type="button" className="mt-2 text-xs text-destructive underline-offset-4 hover:underline" onClick={loadCategories}>{t("supportPublic.createPost.categoriesFailed")}</button> : null}
|
||||
</fieldset>
|
||||
|
||||
<Input id="support-post-title" value={title} onChange={(event) => { setTitle(event.target.value); setFormError("") }} placeholder={t("supportPublic.createPost.postTitlePlaceholder")} className="rounded-md bg-card" disabled={submitting} aria-label={t("supportPublic.createPost.postTitle")} />
|
||||
|
||||
<div className="grid min-w-0 gap-2" role="group" aria-labelledby="support-post-content-label">
|
||||
<span id="support-post-content-label" className="sr-only">{t("supportPublic.createPost.content")}</span>
|
||||
<ContentEditor
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder={t("supportPublic.createPost.contentPlaceholder")}
|
||||
disabled={submitting}
|
||||
allowedModes={["html", "markdown"]}
|
||||
height={420}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input id="support-post-tags" value={tags} onChange={(event) => setTags(event.target.value)} placeholder={t("supportPublic.createPost.tagsPlaceholder")} className="rounded-md bg-card" disabled={submitting} aria-label={t("supportPublic.createPost.tags")} />
|
||||
|
||||
{formError ? <div role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{formError}</div> : null}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button type="submit" disabled={submitting || categoriesLoading || categoriesFailed}>
|
||||
{submitting ? t("supportPublic.actions.publishing") : t("supportPublic.actions.publishPost")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</SupportPageContent>
|
||||
</SupportPageShell>
|
||||
)
|
||||
}
|
||||
5
web/app/(support)/support/community/posts/new/page.tsx
Normal file
5
web/app/(support)/support/community/posts/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CreatePost } from "./_components/create-post"
|
||||
|
||||
export default function SupportCreatePostPage() {
|
||||
return <CreatePost />
|
||||
}
|
||||
5
web/app/(support)/support/community/posts/page.tsx
Normal file
5
web/app/(support)/support/community/posts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PostList } from "./_components/post-list"
|
||||
|
||||
export default function SupportCommunityPostsPage() {
|
||||
return <PostList />
|
||||
}
|
||||
Reference in New Issue
Block a user