added frontend for social + feed partiali working
This commit is contained in:
86
frontend/src/components/social/chat/ChatSidebar.tsx
Normal file
86
frontend/src/components/social/chat/ChatSidebar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiSearch, FiPlus } from "react-icons/fi";
|
||||
import { useApiSocialChatsList } from "@/api/generated/private/chat/chat";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import EmptyState from "@/components/ui/EmptyState";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
|
||||
export default function ChatSidebar() {
|
||||
const { t } = useTranslation("social");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading } = useApiSocialChatsList(
|
||||
query ? { search: query } : undefined,
|
||||
);
|
||||
|
||||
const chats = useMemo(() => data?.results ?? [], [data]);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col border-r border-brand-lines/15">
|
||||
<header className="flex items-center justify-between gap-2 border-b border-brand-lines/10 px-3 py-3">
|
||||
<h2 className="text-sm font-semibold text-brand-text">
|
||||
{t("chat.sidebar.title")}
|
||||
</h2>
|
||||
<IconButton icon={<FiPlus size={16} />} label={t("chat.sidebar.new")} />
|
||||
</header>
|
||||
|
||||
<div className="relative px-3 py-2">
|
||||
<FiSearch
|
||||
className="absolute left-5 top-1/2 -translate-y-1/2 text-brand-text/50"
|
||||
size={14}
|
||||
/>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("chat.sidebar.search")}
|
||||
className="w-full rounded-xl bg-brand-bgLight/40 border border-brand-lines/20 py-2 pl-8 pr-2 text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none focus:border-brand-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner size={20} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && chats.length === 0 && (
|
||||
<EmptyState message={t("chat.sidebar.empty")} />
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{chats.map((chat) => (
|
||||
<li key={chat.id}>
|
||||
<NavLink
|
||||
to={`/social/chats/${chat.id}`}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 px-3 py-2.5 hover:bg-brand-lines/10 transition-colors",
|
||||
isActive ? "bg-brand-lines/10" : "",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
name={chat.name ?? `chat ${chat.id}`}
|
||||
src={chat.icon ?? undefined}
|
||||
size={36}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-brand-text">
|
||||
{chat.name || `Chat #${chat.id}`}
|
||||
</div>
|
||||
<div className="truncate text-xs text-brand-text/60">
|
||||
{chat.chat_type}
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiTrash2, FiCornerUpLeft, FiSmile } from "react-icons/fi";
|
||||
import type { Message as MessageModel } from "@/api/generated/private/models/message";
|
||||
import type { Chat } from "@/api/generated/private/models/chat";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { canDeleteMessage } from "@/hooks/usePermissions";
|
||||
import { formatRelative } from "@/utils/relativeTime";
|
||||
import { apiSocialMessagesDestroy } from "@/api/generated/private/chat/chat";
|
||||
|
||||
interface Props {
|
||||
message: MessageModel;
|
||||
chat: Chat | null;
|
||||
onReply?: (message: MessageModel) => void;
|
||||
onReact?: (message: MessageModel, emoji: string) => void;
|
||||
}
|
||||
|
||||
export default function Message({ message, chat, onReply, onReact }: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
const { user } = useAuth();
|
||||
const isOwn = user?.id != null && message.sender === user.id;
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm("Smazat zprávu?")) return;
|
||||
await apiSocialMessagesDestroy(String(message.id));
|
||||
// WS delete event will remove from the list; refresh cache as fallback.
|
||||
}
|
||||
|
||||
const canDelete = canDeleteMessage(user, message, chat);
|
||||
|
||||
return (
|
||||
<div className={`group flex gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}>
|
||||
<Avatar name={`user ${message.sender}`} size={28} />
|
||||
<div className={`flex max-w-[70%] flex-col ${isOwn ? "items-end" : "items-start"}`}>
|
||||
<div
|
||||
className={[
|
||||
"rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap",
|
||||
isOwn
|
||||
? "bg-brand-accent text-brand-bg rounded-br-sm"
|
||||
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",
|
||||
].join(" ")}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-brand-text/50">
|
||||
<time dateTime={String(message.created_at)}>
|
||||
{formatRelative(message.created_at)}
|
||||
</time>
|
||||
{message.is_edited && <span>· {t("chat.room.edited")}</span>}
|
||||
</div>
|
||||
{message.reactions?.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{message.reactions.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="rounded-full bg-brand-bgLight/60 px-2 py-0.5 text-xs"
|
||||
>
|
||||
{r.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<IconButton
|
||||
icon={<FiCornerUpLeft size={14} />}
|
||||
label={t("chat.actions.reply")}
|
||||
onClick={() => onReply?.(message)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiSmile size={14} />}
|
||||
label={t("chat.actions.react")}
|
||||
onClick={() => onReact?.(message, "👍")}
|
||||
/>
|
||||
{canDelete && (
|
||||
<IconButton
|
||||
icon={<FiTrash2 size={14} />}
|
||||
label={t("chat.actions.delete")}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
112
frontend/src/components/social/chat/MessageComposer.tsx
Normal file
112
frontend/src/components/social/chat/MessageComposer.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiSend, FiX } from "react-icons/fi";
|
||||
import type { Message } from "@/api/generated/private/models/message";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
replyTo?: Message | null;
|
||||
onCancelReply?: () => void;
|
||||
onSend: (text: string, replyToId?: number) => boolean;
|
||||
onTyping?: (isTyping: boolean) => void;
|
||||
}
|
||||
|
||||
export default function MessageComposer({
|
||||
disabled,
|
||||
replyTo,
|
||||
onCancelReply,
|
||||
onSend,
|
||||
onTyping,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
const [text, setText] = useState("");
|
||||
const typingTimerRef = useRef<number | null>(null);
|
||||
const isTypingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typingTimerRef.current) window.clearTimeout(typingTimerRef.current);
|
||||
if (isTypingRef.current) onTyping?.(false);
|
||||
};
|
||||
}, [onTyping]);
|
||||
|
||||
function notifyTyping() {
|
||||
if (!isTypingRef.current) {
|
||||
isTypingRef.current = true;
|
||||
onTyping?.(true);
|
||||
}
|
||||
if (typingTimerRef.current) window.clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = window.setTimeout(() => {
|
||||
isTypingRef.current = false;
|
||||
onTyping?.(false);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = onSend(trimmed, replyTo?.id);
|
||||
if (ok) {
|
||||
setText("");
|
||||
if (isTypingRef.current) {
|
||||
isTypingRef.current = false;
|
||||
onTyping?.(false);
|
||||
}
|
||||
onCancelReply?.();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="border-t border-brand-lines/15 bg-brand-bg/60 px-4 py-3"
|
||||
>
|
||||
{replyTo && (
|
||||
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-brand-lines/15 bg-brand-bgLight/40 px-3 py-1.5 text-xs text-brand-text/80">
|
||||
<span className="truncate">
|
||||
{t("chat.composer.replyTo", {
|
||||
snippet: (replyTo.content ?? "").slice(0, 60),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="rounded-full p-1 hover:bg-brand-lines/10"
|
||||
aria-label={t("chat.composer.cancelReply")}
|
||||
>
|
||||
<FiX size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
notifyTyping();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
placeholder={t("chat.composer.placeholder")}
|
||||
className="min-h-[42px] max-h-[160px] flex-1 resize-none rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-3 py-2 text-sm text-brand-text placeholder:text-brand-text/40 focus:outline-none focus:border-brand-accent"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !text.trim()}
|
||||
leftIcon={<FiSend size={14} />}
|
||||
>
|
||||
{t("common:send", { defaultValue: "Odeslat" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/social/posts/MediaGallery.tsx
Normal file
46
frontend/src/components/social/posts/MediaGallery.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { PostContent } from "@/api/generated/private/models/postContent";
|
||||
|
||||
interface Props {
|
||||
items: readonly PostContent[];
|
||||
}
|
||||
|
||||
export default function MediaGallery({ items }: Props) {
|
||||
if (!items?.length) return null;
|
||||
|
||||
const layoutClass =
|
||||
items.length === 1
|
||||
? "grid-cols-1"
|
||||
: items.length === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-2";
|
||||
|
||||
return (
|
||||
<div className={`mt-3 grid ${layoutClass} gap-2 overflow-hidden rounded-xl border border-brand-lines/15`}>
|
||||
{items.map((it) => (
|
||||
<MediaItem key={it.id} item={it} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaItem({ item }: { item: PostContent }) {
|
||||
const url = item.file ?? "";
|
||||
const mime = item.mime_type ?? "";
|
||||
if (mime.startsWith("video/")) {
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="w-full max-h-[480px] object-cover bg-black"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={item.alt_text ?? ""}
|
||||
className="w-full max-h-[480px] object-cover bg-brand-bg/60"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
136
frontend/src/components/social/posts/PostActions.tsx
Normal file
136
frontend/src/components/social/posts/PostActions.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiMessageSquare, FiArrowUp, FiArrowDown, FiShare2 } from "react-icons/fi";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import { apiSocialPostsVoteCreate } from "@/api/generated/private/posts/posts";
|
||||
import SharePopup from "./SharePopup";
|
||||
|
||||
interface Props {
|
||||
postId: number;
|
||||
replyCount?: number;
|
||||
voteScore?: number;
|
||||
initialUserVote?: -1 | 0 | 1;
|
||||
onReplyClick?: () => void;
|
||||
}
|
||||
|
||||
export default function PostActions({
|
||||
postId,
|
||||
replyCount,
|
||||
voteScore,
|
||||
initialUserVote,
|
||||
onReplyClick,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
const [vote, setVote] = useState<-1 | 0 | 1>(initialUserVote ?? 0);
|
||||
const [score, setScore] = useState(voteScore ?? 0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showShare, setShowShare] = useState(false);
|
||||
const [upHover, setUpHover] = useState(false);
|
||||
const [downHover, setDownHover] = useState(false);
|
||||
|
||||
async function castVote(value: 1 | -1) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const next: -1 | 0 | 1 = vote === value ? 0 : value;
|
||||
const delta = next - vote;
|
||||
setScore((s) => s + delta);
|
||||
setVote(next);
|
||||
await apiSocialPostsVoteCreate(postId, { vote: value } as never);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const upActive = vote === 1;
|
||||
const downActive = vote === -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-3 text-brand-text/70">
|
||||
{/* Reply */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReplyClick}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-sm hover:bg-brand-lines/10 hover:text-brand-accent transition-colors"
|
||||
aria-label={t("post.actions.reply")}
|
||||
title={t("post.actions.reply")}
|
||||
>
|
||||
<FiMessageSquare size={16} />
|
||||
{typeof replyCount === "number" && replyCount > 0 && (
|
||||
<span className="text-xs tabular-nums">{replyCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Vote pill */}
|
||||
<div className="inline-flex items-center overflow-hidden rounded-full border border-brand-lines/20">
|
||||
{/* Upvote — pseudo-element carries the gradient so opacity can transition */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => castVote(1)}
|
||||
onMouseEnter={() => setUpHover(true)}
|
||||
onMouseLeave={() => setUpHover(false)}
|
||||
aria-label={t("post.actions.upvote")}
|
||||
title={t("post.actions.upvote")}
|
||||
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
||||
className={[
|
||||
"relative flex items-center justify-center px-2.5 py-1.5 disabled:opacity-50",
|
||||
"before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200",
|
||||
upActive ? "before:opacity-100" : upHover ? "before:opacity-40" : "before:opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<FiArrowUp
|
||||
size={15}
|
||||
style={{ color: upActive ? "rgb(12 74 110)" : undefined }}
|
||||
className="relative z-10 text-brand-text/50"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"min-w-[2rem] px-1.5 text-center text-xs tabular-nums transition-colors duration-200",
|
||||
upActive || downActive ? "!text-sky-500" : "text-brand-text/50",
|
||||
].join(" ")}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
|
||||
{/* Downvote — same pseudo-element trick */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => castVote(-1)}
|
||||
onMouseEnter={() => setDownHover(true)}
|
||||
onMouseLeave={() => setDownHover(false)}
|
||||
aria-label={t("post.actions.downvote")}
|
||||
title={t("post.actions.downvote")}
|
||||
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
|
||||
className={[
|
||||
"relative flex items-center justify-center rounded-r-full rounded-l-none border-none px-2.5 py-1.5 disabled:opacity-50",
|
||||
"before:absolute before:inset-0 before:bg-gradient-to-br before:from-white/90 before:to-sky-200/60 before:transition-opacity before:duration-200",
|
||||
downActive ? "before:opacity-100" : downHover ? "before:opacity-40" : "before:opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<FiArrowDown
|
||||
size={15}
|
||||
style={{ color: downActive ? "rgb(12 74 110)" : undefined }}
|
||||
className="relative z-10 text-brand-text/50"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Share */}
|
||||
<IconButton
|
||||
icon={<FiShare2 size={16} />}
|
||||
label={t("post.actions.share")}
|
||||
onClick={() => setShowShare(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showShare && (
|
||||
<SharePopup postId={postId} onClose={() => setShowShare(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
frontend/src/components/social/posts/PostComposer.tsx
Normal file
190
frontend/src/components/social/posts/PostComposer.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiSend, FiImage, FiX } from "react-icons/fi";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Textarea from "@/components/ui/Textarea";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||
import { applyServerErrors } from "@/utils/formErrors";
|
||||
import { apiSocialPostsCreate } from "@/api/generated/private/posts/posts";
|
||||
import { useApiSocialHubsList } from "@/api/generated/private/hubs/hubs";
|
||||
import { privateApi } from "@/api/privateClient";
|
||||
|
||||
interface Props {
|
||||
parentId?: number;
|
||||
defaultHubId?: number | null;
|
||||
onPosted?: () => void;
|
||||
}
|
||||
|
||||
interface ComposerForm {
|
||||
content: string;
|
||||
hub: number | null;
|
||||
}
|
||||
|
||||
export default function PostComposer({ parentId, defaultHubId, onPosted }: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
const queryClient = useQueryClient();
|
||||
const { data: hubsData } = useApiSocialHubsList(undefined);
|
||||
const [rootError, setRootError] = useState<string | undefined>();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const form = useForm<ComposerForm>({
|
||||
defaultValues: { content: "", hub: defaultHubId ?? null },
|
||||
});
|
||||
const { register, handleSubmit, formState, reset, watch, clearErrors } = form;
|
||||
const { errors, isSubmitting } = formState;
|
||||
|
||||
const hubs = hubsData?.results ?? [];
|
||||
const content = watch("content");
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
const newPreviews = picked.map((f) => URL.createObjectURL(f));
|
||||
setFiles((prev) => [...prev, ...picked]);
|
||||
setPreviews((prev) => [...prev, ...newPreviews]);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
URL.revokeObjectURL(previews[index]);
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
setPreviews((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function onSubmit(values: ComposerForm) {
|
||||
setRootError(undefined);
|
||||
clearErrors();
|
||||
try {
|
||||
const created = await apiSocialPostsCreate({
|
||||
content: values.content,
|
||||
hub: values.hub ?? null,
|
||||
reply_to: parentId ?? null,
|
||||
} as Parameters<typeof apiSocialPostsCreate>[0]);
|
||||
|
||||
// Upload each file to the new post
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
await privateApi.post(`/api/social/posts/${created.id}/media/`, fd);
|
||||
}
|
||||
|
||||
previews.forEach((url) => URL.revokeObjectURL(url));
|
||||
setFiles([]);
|
||||
setPreviews([]);
|
||||
reset({ content: "", hub: defaultHubId ?? null });
|
||||
await queryClient.invalidateQueries({ queryKey: ["social", "posts"] });
|
||||
onPosted?.();
|
||||
} catch (err) {
|
||||
setRootError(applyServerErrors(form, err));
|
||||
}
|
||||
}
|
||||
|
||||
function onInvalid() {
|
||||
setRootError(undefined);
|
||||
}
|
||||
|
||||
const hasContent = !!content?.trim() || files.length > 0;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit, onInvalid)}
|
||||
className="border-b border-brand-lines/10 px-4 py-3"
|
||||
noValidate
|
||||
>
|
||||
<FormErrorBanner message={rootError} className="mb-2" />
|
||||
|
||||
<Textarea
|
||||
placeholder={
|
||||
parentId
|
||||
? t("post.compose.replyPlaceholder")
|
||||
: t("post.compose.placeholder")
|
||||
}
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
error={errors.content?.message}
|
||||
{...register("content", {
|
||||
validate: (v) => files.length > 0 || v.trim().length > 0 || true,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Image previews */}
|
||||
{previews.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{previews.map((src, i) => (
|
||||
<div key={src} className="relative">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-xl object-cover border border-brand-lines/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(i)}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-brand-bg border border-brand-lines/30 text-brand-text/70 hover:text-brand-text shadow"
|
||||
aria-label={t("post.compose.removeImage")}
|
||||
>
|
||||
<FiX size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Hub selector — top-level posts only */}
|
||||
{!parentId && (
|
||||
<select
|
||||
disabled={isSubmitting}
|
||||
className="rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 px-2 py-1.5 text-sm text-brand-text focus:outline-none focus:border-brand-accent"
|
||||
{...register("hub", {
|
||||
setValueAs: (v) => (v === "" || v == null ? null : Number(v)),
|
||||
})}
|
||||
>
|
||||
<option value="">{t("post.compose.noHub")}</option>
|
||||
{hubs.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Image attach button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center justify-center h-9 w-9 rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-accent transition-colors disabled:opacity-40"
|
||||
aria-label={t("post.compose.attachImage")}
|
||||
title={t("post.compose.attachImage")}
|
||||
>
|
||||
<FiImage size={16} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !hasContent}
|
||||
leftIcon={isSubmitting ? <Spinner size={14} /> : <FiSend size={14} />}
|
||||
>
|
||||
{isSubmitting
|
||||
? t("post.compose.submitting")
|
||||
: t("post.compose.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/social/posts/SharePopup.tsx
Normal file
101
frontend/src/components/social/posts/SharePopup.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiX, FiLink, FiShare2, FiCheck } from "react-icons/fi";
|
||||
|
||||
interface Props {
|
||||
postId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SharePopup({ postId, onClose }: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
const url = `${window.location.origin}/social/post/${postId}`;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback for browsers without clipboard API
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = url;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async function nativeShare() {
|
||||
await navigator.share({ url, title: t("post.share.nativeTitle") });
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
className="relative w-full max-w-sm rounded-2xl border border-brand-lines/20 bg-brand-bg shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-brand-lines/10 px-5 py-4">
|
||||
<h3 className="font-semibold text-brand-text">{t("post.share.title")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/10 hover:text-brand-text transition-colors"
|
||||
aria-label={t("post.share.close")}
|
||||
>
|
||||
<FiX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* URL preview */}
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-brand-lines/20 bg-brand-bgLight/30 px-3 py-2.5">
|
||||
<FiLink size={14} className="shrink-0 text-brand-text/40" />
|
||||
<span className="truncate text-xs text-brand-text/60">{url}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 px-5 pb-5 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyLink}
|
||||
className={[
|
||||
"flex items-center gap-3 rounded-xl border px-4 py-3 text-sm font-medium transition-colors",
|
||||
copied
|
||||
? "border-green-500/40 bg-green-500/10 text-green-400"
|
||||
: "border-brand-lines/20 bg-brand-boxes/20 text-brand-text hover:bg-brand-boxes/40",
|
||||
].join(" ")}
|
||||
>
|
||||
{copied ? <FiCheck size={16} /> : <FiLink size={16} />}
|
||||
{copied ? t("post.share.copied") : t("post.share.copyLink")}
|
||||
</button>
|
||||
|
||||
{typeof navigator.share === "function" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={nativeShare}
|
||||
className="flex items-center gap-3 rounded-xl border border-brand-lines/20 bg-brand-boxes/20 px-4 py-3 text-sm font-medium text-brand-text hover:bg-brand-boxes/40 transition-colors"
|
||||
>
|
||||
<FiShare2 size={16} />
|
||||
{t("post.share.shareVia")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user