Files
vontor-cz/frontend/src/components/social/posts/Post.tsx

178 lines
5.7 KiB
TypeScript

import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FiTrash2, FiMoreHorizontal } from "react-icons/fi";
import type { Post } from "@/api/generated/private/models/post";
import Avatar from "@/components/ui/Avatar";
import IconButton from "@/components/ui/IconButton";
import { useAuth } from "@/context/AuthContext";
import { canDeletePost, canEditPost } from "@/hooks/usePermissions";
import { formatRelative } from "@/utils/relativeTime";
import { apiSocialPostsDestroy } from "@/api/generated/private/posts/posts";
import { useQueryClient } from "@tanstack/react-query";
import MediaGallery from "./MediaGallery";
import PostActions from "./PostActions";
// Extended until orval regeneration adds these fields to the generated Post type
interface AuthorDetail {
id: number;
username: string;
first_name: string;
last_name: string;
avatar: string | null;
}
type EnrichedPost = Post & {
author_detail?: AuthorDetail;
vote_score?: number;
user_vote?: -1 | 0 | 1;
reply_count?: number;
};
interface Props {
post: EnrichedPost;
variant?: "compact" | "default" | "focused";
clickable?: boolean;
onReplyClick?: () => void;
}
export default function Post({
post,
variant = "default",
clickable = true,
onReplyClick,
}: Props) {
const { t } = useTranslation("social");
const { user } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isFocused = variant === "focused";
const isCompact = variant === "compact";
const author = post.author_detail;
const displayName = author?.username ?? `user${post.author}`;
const fullName =
author && (author.first_name || author.last_name)
? `${author.first_name} ${author.last_name}`.trim()
: displayName;
async function handleDelete(e: React.MouseEvent) {
e.stopPropagation();
if (!confirm("Smazat příspěvek?")) return;
await apiSocialPostsDestroy(post.id);
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
}
function open(e: React.MouseEvent) {
if (!clickable) return;
const target = e.target as HTMLElement;
if (target.closest("button, a")) return;
navigate(`/social/post/${post.id}`);
}
const canDelete = canDeletePost(user, post);
const canEdit = canEditPost(user, post);
return (
<article
onClick={open}
className={[
"border-b border-brand-lines/10 px-4 py-3",
isFocused ? "bg-brand-lines/5" : "",
clickable ? "cursor-pointer hover:bg-brand-lines/5 transition-colors" : "",
].join(" ")}
>
<div className="flex gap-3">
<Link
to={`/social/profile/${author?.id ?? post.author}`}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
<Avatar
name={fullName}
src={author?.avatar ?? null}
size={isCompact ? 32 : 40}
/>
</Link>
<div className="min-w-0 flex-1">
<header className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0 text-sm">
<Link
to={`/social/profile/${author?.id ?? post.author}`}
onClick={(e) => e.stopPropagation()}
className="font-semibold text-brand-text hover:underline"
>
@{displayName}
</Link>
{post.hub != null && (
<Link
to={`/social/hub/${post.hub}`}
onClick={(e) => e.stopPropagation()}
className="rounded-full bg-brand-boxes/40 px-2 py-0.5 text-xs text-brand-text hover:bg-brand-boxes/60"
>
{t("hub.badge")}
</Link>
)}
<span className="text-brand-text/50">·</span>
<time className="text-xs text-brand-text/60" dateTime={String(post.created_at)}>
{formatRelative(post.created_at)}
</time>
</div>
{(canDelete || canEdit) && (
<div className="flex items-center gap-1">
{canDelete && (
<IconButton
icon={<FiTrash2 size={14} />}
label={t("post.actions.delete")}
onClick={handleDelete}
/>
)}
{canEdit && (
<IconButton
icon={<FiMoreHorizontal size={14} />}
label={t("post.actions.more")}
/>
)}
</div>
)}
</header>
{post.content && (
<p className={`mt-1 whitespace-pre-wrap break-words text-brand-text ${isFocused ? "text-lg" : "text-[15px]"}`}>
{post.content}
</p>
)}
{post.contents?.length > 0 && <MediaGallery items={post.contents} />}
{post.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<span
key={tag.id}
className="rounded-full border border-brand-lines/20 bg-brand-lines/5 px-2 py-0.5 text-[11px] text-brand-text/80"
style={{ borderColor: tag.color ?? undefined }}
>
{tag.name}
</span>
))}
</div>
)}
{!isCompact && (
<PostActions
postId={post.id}
replyCount={post.reply_count}
voteScore={post.vote_score}
initialUserVote={post.user_vote}
onReplyClick={onReplyClick}
/>
)}
</div>
</div>
</article>
);
}