added frontend for social + feed partiali working
This commit is contained in:
@@ -1 +1,177 @@
|
||||
/* POST container */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user