Files
67/apps/web/src/pages/MaterialPage.jsx
2026-06-22 20:13:14 +03:00

283 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}