178 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|