283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
import { Bookmark, Clock3, Eye, MessageCircle, Send, Share2, Star } from "lucide-react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useMemo, useState } from "react";
|
||
import { Link, useParams } from "react-router-dom";
|
||
import { useSession } from "../app/store/session";
|
||
import { commentsApi, contentApi } from "../shared/api/endpoints";
|
||
import { normalizeContentItem, normalizeContentList, toEntity, toList } from "../shared/api/normalize";
|
||
import { queryKeys } from "../shared/api/queryKeys";
|
||
import { Badge } from "../shared/ui/Badge";
|
||
import { Button } from "../shared/ui/Button";
|
||
import { Textarea } from "../shared/ui/Field";
|
||
import { MaterialCard } from "../shared/ui/MaterialCard";
|
||
import { ResponsiveImage } from "../shared/ui/ResponsiveImage";
|
||
import { EmptyState, ErrorState, Skeleton } from "../shared/ui/States";
|
||
import { NotFoundPage } from "./NotFoundPage";
|
||
|
||
function normalizeComment(comment) {
|
||
return {
|
||
id: comment.id ?? `${comment.createdAt ?? Date.now()}-${comment.author?.id ?? comment.user?.id ?? "comment"}`,
|
||
author: comment.author?.name ?? comment.author ?? comment.user?.name ?? "Гость",
|
||
text: comment.text ?? comment.content ?? comment.body ?? "",
|
||
createdAt: comment.createdAt ?? comment.date ?? "",
|
||
};
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
|
||
}
|
||
|
||
function RatingBlock({ material, detailKey }) {
|
||
const queryClient = useQueryClient();
|
||
const initialAverage = Number(material.ratingAverage ?? material.rating ?? 0);
|
||
const initialCount = Number(material.ratingCount ?? material.ratingsCount ?? 0);
|
||
const currentRating = Number(material.myRating ?? 0);
|
||
const [selectedRating, setSelectedRating] = useState(currentRating);
|
||
|
||
const rateMutation = useMutation({
|
||
mutationFn: (rating) => contentApi.update(material.id, { rating }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.contentDetail(detailKey) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.contentDetail(material.id) });
|
||
},
|
||
});
|
||
|
||
const average = initialAverage.toFixed(1);
|
||
|
||
return (
|
||
<section className="rounded-lg border border-line bg-surface p-5">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h2 className="font-serif text-xl">Оцените материал</h2>
|
||
<p className="mt-1 text-sm text-muted">Средняя оценка: {average} из 5, оценок: {initialCount}</p>
|
||
</div>
|
||
<span className="font-serif text-3xl text-primary">{average}</span>
|
||
</div>
|
||
<div className="mt-4 flex gap-1" role="radiogroup" aria-label="Оценка материала">
|
||
{[1, 2, 3, 4, 5].map((value) => (
|
||
<button
|
||
key={value}
|
||
type="button"
|
||
className="rounded-md p-1 text-warning transition hover:bg-warning-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||
onClick={() => {
|
||
setSelectedRating(value);
|
||
rateMutation.mutate(value);
|
||
}}
|
||
aria-label={`Поставить ${value}`}
|
||
aria-checked={selectedRating === value}
|
||
role="radio"
|
||
>
|
||
<Star
|
||
className="size-6"
|
||
fill={value <= (selectedRating || Math.round(initialAverage)) ? "currentColor" : "none"}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{rateMutation.isPending && <p className="mt-3 text-sm font-semibold text-muted">Сохраняем оценку...</p>}
|
||
{rateMutation.isSuccess && <p className="mt-3 text-sm font-semibold text-success">Спасибо, ваша оценка учтена.</p>}
|
||
{rateMutation.isError && <p className="mt-3 text-sm font-semibold text-danger">Не удалось сохранить оценку.</p>}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function CommentsBlock({ materialId }) {
|
||
const user = useSession((state) => state.user);
|
||
const [commentText, setCommentText] = useState("");
|
||
const [notice, setNotice] = useState("");
|
||
const queryClient = useQueryClient();
|
||
|
||
const commentsQuery = useQuery({
|
||
queryKey: queryKeys.comments(materialId),
|
||
queryFn: () => commentsApi.list(materialId),
|
||
enabled: Boolean(materialId),
|
||
});
|
||
|
||
const createComment = useMutation({
|
||
mutationFn: (text) => commentsApi.create(materialId, { text }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.comments(materialId) });
|
||
setCommentText("");
|
||
setNotice("Комментарий добавлен.");
|
||
},
|
||
});
|
||
|
||
const comments = useMemo(() => toList(commentsQuery.data).map(normalizeComment), [commentsQuery.data]);
|
||
|
||
const submitComment = (event) => {
|
||
event.preventDefault();
|
||
const text = commentText.trim();
|
||
if (!text) return;
|
||
|
||
setNotice("");
|
||
createComment.mutate(text);
|
||
};
|
||
|
||
return (
|
||
<section className="page-shell border-t border-line py-14">
|
||
<div className="flex items-center gap-3">
|
||
<MessageCircle className="size-6 text-primary" />
|
||
<h2 className="font-serif text-3xl">Комментарии</h2>
|
||
<Badge>{comments.length}</Badge>
|
||
</div>
|
||
|
||
<form className="mt-6 rounded-lg border border-line bg-surface p-5" onSubmit={submitComment}>
|
||
<Textarea
|
||
label={user ? "Ваш комментарий" : "Комментарий гостя"}
|
||
value={commentText}
|
||
onChange={(event) => setCommentText(event.target.value)}
|
||
placeholder="Напишите, что думаете о материале..."
|
||
/>
|
||
<div className="mt-4 flex items-center gap-4">
|
||
<Button type="submit" loading={createComment.isPending} icon={<Send className="size-4" />}>
|
||
Отправить
|
||
</Button>
|
||
{notice && <span className="text-sm font-semibold text-success">{notice}</span>}
|
||
{createComment.isError && <span className="text-sm font-semibold text-danger">Не удалось отправить комментарий.</span>}
|
||
</div>
|
||
</form>
|
||
|
||
<div className="mt-7 grid gap-4">
|
||
{commentsQuery.isLoading ? (
|
||
[1, 2].map((item) => <Skeleton key={item} className="h-28 w-full" />)
|
||
) : commentsQuery.isError ? (
|
||
<ErrorState retry={commentsQuery.refetch} />
|
||
) : comments.length ? (
|
||
comments.map((comment) => (
|
||
<article key={comment.id} className="rounded-lg border border-line bg-surface p-5">
|
||
<div className="flex items-baseline justify-between gap-4">
|
||
<h3 className="font-semibold text-ink">{comment.author}</h3>
|
||
<span className="text-xs text-muted">{formatDate(comment.createdAt)}</span>
|
||
</div>
|
||
<p className="mt-3 leading-7 text-muted">{comment.text}</p>
|
||
</article>
|
||
))
|
||
) : (
|
||
<EmptyState title="Комментариев пока нет" text="Оставленные через API комментарии появятся в этом блоке." />
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function MaterialPage() {
|
||
const { slug } = useParams();
|
||
const contentQuery = useQuery({
|
||
queryKey: queryKeys.contentDetail(slug),
|
||
queryFn: () => contentApi.get(slug),
|
||
enabled: Boolean(slug),
|
||
});
|
||
const material = normalizeContentItem(toEntity(contentQuery.data));
|
||
const materialId = material?.id ?? material?.slug;
|
||
const relatedQuery = useQuery({
|
||
queryKey: queryKeys.content({ relatedTo: materialId }),
|
||
queryFn: () => contentApi.list({ limit: 3, exclude: materialId }),
|
||
enabled: Boolean(materialId),
|
||
});
|
||
const relatedMaterials = normalizeContentList(relatedQuery.data).filter((item) => item.id !== materialId);
|
||
|
||
if (contentQuery.isLoading) {
|
||
return (
|
||
<div className="page-shell grid gap-6 py-10">
|
||
<Skeleton className="h-80 w-full" />
|
||
<Skeleton className="h-96 w-full" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (contentQuery.isError) {
|
||
return (
|
||
<div className="page-shell py-16">
|
||
<ErrorState retry={contentQuery.refetch} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!material?.id) return <NotFoundPage />;
|
||
|
||
const paragraphs = String(material.content ?? material.body ?? "").split(/\n{2,}/).filter(Boolean);
|
||
|
||
return (
|
||
<article>
|
||
<header className="border-b border-line">
|
||
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-16">
|
||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||
<Link className="text-muted hover:text-primary" to="/materials">
|
||
Материалы
|
||
</Link>
|
||
<span className="text-line">/</span>
|
||
<Badge tone="accent">{material.category}</Badge>
|
||
</div>
|
||
<h1 className="mt-6 max-w-4xl font-serif text-4xl leading-[1.08] tracking-[-0.025em] sm:text-6xl">
|
||
{material.title}
|
||
</h1>
|
||
<p className="mt-6 max-w-3xl text-lg leading-8 text-muted">{material.excerpt}</p>
|
||
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-line pt-6 text-sm text-muted">
|
||
<span className="font-semibold text-ink">{material.author}</span>
|
||
<span>{formatDate(material.publishedAt)}</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<Clock3 className="size-4" /> {material.readingTime} мин
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<Eye className="size-4" /> {Number(material.views ?? 0).toLocaleString("ru-RU")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="page-shell py-8">
|
||
<ResponsiveImage src={material.image} alt="" eager className="aspect-[16/8] w-full rounded-xl object-cover" />
|
||
</div>
|
||
|
||
<div className="page-shell grid gap-10 py-8 lg:grid-cols-[minmax(0,4fr)_minmax(14rem,1fr)]">
|
||
<div className="max-w-[68ch]">
|
||
{paragraphs.map((paragraph) => (
|
||
<p key={paragraph} className="mb-6 font-serif text-xl leading-9 text-ink/90">
|
||
{paragraph}
|
||
</p>
|
||
))}
|
||
<div className="mt-10 flex flex-wrap gap-2 border-t border-line pt-6">
|
||
{(material.tags ?? []).map((tag) => (
|
||
<Badge key={tag}>#{tag}</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<aside className="order-first lg:order-last">
|
||
<div className="grid gap-3 lg:sticky lg:top-24">
|
||
<RatingBlock material={material} detailKey={slug} />
|
||
<div className="grid gap-2 rounded-lg border border-line bg-surface p-3">
|
||
<Button variant="secondary" icon={<Bookmark className="size-4" />}>Сохранить</Button>
|
||
<Button variant="ghost" icon={<Share2 className="size-4" />}>Поделиться</Button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<CommentsBlock materialId={materialId} />
|
||
|
||
<section className="page-shell border-t border-line py-14">
|
||
<h2 className="font-serif text-3xl">Читайте также</h2>
|
||
<div className="mt-7 grid gap-6 md:grid-cols-3">
|
||
{relatedQuery.isLoading && [1, 2, 3].map((item) => <Skeleton key={item} className="h-72" />)}
|
||
{relatedQuery.isError && (
|
||
<div className="md:col-span-3">
|
||
<ErrorState retry={relatedQuery.refetch} />
|
||
</div>
|
||
)}
|
||
{relatedQuery.isSuccess && relatedMaterials.length > 0 && relatedMaterials.slice(0, 3).map((item) => (
|
||
<MaterialCard key={item.id} material={item} compact />
|
||
))}
|
||
{relatedQuery.isSuccess && relatedMaterials.length === 0 && (
|
||
<div className="md:col-span-3">
|
||
<EmptyState title="Похожих материалов нет" text="Backend пока не вернул дополнительные записи." />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</article>
|
||
);
|
||
}
|