added frontend for social + feed partiali working

This commit is contained in:
2026-05-18 02:25:47 +02:00
parent e1df55df0e
commit 202ce22102
88 changed files with 4236 additions and 737 deletions

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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"
/>
);
}

View File

@@ -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>
);
}

View 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)} />
)}
</>
);
}

View 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>
);
}

View 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,
);
}