chore: code refactor
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostActionsFooter — the icon-button row at the bottom of a post card.
|
||||
*
|
||||
* Left cluster (non-owner): Like · Comment · Reshare
|
||||
* Left cluster (owner): Like · Comment · Send
|
||||
* Right cluster: Save to Collection (folder-plus)
|
||||
*
|
||||
* Reshare and Send are *mutually exclusive* — once the post is on your
|
||||
* account there's nothing left to reshare onto it, so the Reshare slot
|
||||
* is replaced by Send (external share via SharePostSheet). Ownership is
|
||||
* decided by `user.id === post.user_id`, which the BFF already includes
|
||||
* on every post fetch — no extra round-trip needed.
|
||||
*
|
||||
* Like / Comment / Reshare are placeholders today (toast on click).
|
||||
* Save to Collection mounts the existing CollectionsDrawer in
|
||||
* `existingPostId` mode so the post attaches without a duplicate.
|
||||
*
|
||||
* Auth gating is handled per-action via `useAuthGate` — anonymous users
|
||||
* see the in-place auth modal before the underlying action runs.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Loader2, Pin } from "lucide-react";
|
||||
import {
|
||||
HiHeart,
|
||||
HiOutlineArrowPath,
|
||||
HiOutlineChatBubbleOvalLeft,
|
||||
HiOutlineFolderPlus,
|
||||
HiOutlineHeart,
|
||||
HiOutlinePaperAirplane,
|
||||
} from "react-icons/hi2";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
posts as postsApi,
|
||||
SocialError,
|
||||
type Comment,
|
||||
type Post,
|
||||
} from "@/lib/social";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
||||
import { PostLikesDrawer } from "./post-likes-drawer";
|
||||
import { PostCommentsDrawer } from "@/components/social/comment/post-comments-drawer";
|
||||
import { useSharePost } from "../share-post-provider";
|
||||
|
||||
// All icons come from the Heroicons v2 outline family (react-icons
|
||||
// `hi2` namespace), so they share one design language — uniform stroke
|
||||
// weight, matching corner radius, matching optical sizing.
|
||||
const ICON_CLASS = "size-[1.375rem]";
|
||||
|
||||
export function PostActionsFooter({ post }: { post: Post }) {
|
||||
const { user } = useAuth();
|
||||
const { requireAuth } = useAuthGate();
|
||||
const { openShareSheet } = useSharePost();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
||||
// Tracks the platform-aware share routing (short URL + image fetch
|
||||
// on mobile native-share; sheet mount on desktop) so the Send icon
|
||||
// can render a spinner while the user waits.
|
||||
const [sharePending, setSharePending] = useState(false);
|
||||
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
||||
// happens the instant the user taps Reshare. Reconciles to the
|
||||
// server's value via rollback if the API call fails.
|
||||
const [reshared, setReshared] = useState<boolean>(
|
||||
!!post.viewer_has_reshared,
|
||||
);
|
||||
// Like state — seeded from the hydrated post, toggled optimistically and
|
||||
// reconciled to the server's effective count on success.
|
||||
const [liked, setLiked] = useState<boolean>(!!post.viewer_has_liked);
|
||||
const [likeCount, setLikeCount] = useState<number>(post.likes_count ?? 0);
|
||||
const [likesOpen, setLikesOpen] = useState(false);
|
||||
const likeBusy = useRef(false);
|
||||
// Comments drawer + the post's pinned comment (shown below the action row
|
||||
// even when the drawer is closed). Seeded from the hydrated post; kept in
|
||||
// sync as the author pins/unpins inside the drawer.
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [pinned, setPinned] = useState<Comment | null>(
|
||||
post.pinned_comment ?? null,
|
||||
);
|
||||
const [commentCount, setCommentCount] = useState<number>(
|
||||
post.comments_count ?? 0,
|
||||
);
|
||||
|
||||
const isOwner = !!user && user.id === post.user_id;
|
||||
// Owners and resharers both see Send — the post is "on their account"
|
||||
// either way, so the action they need is external share, not reshare.
|
||||
const showSend = isOwner || reshared;
|
||||
|
||||
async function withAuth(fn: () => void | Promise<void>) {
|
||||
if (!(await requireAuth())) return;
|
||||
await fn();
|
||||
}
|
||||
|
||||
// Like toggle — optimistic flip + count nudge, reconciled to the server's
|
||||
// effective total (DB count + pending Redis delta) on success, rolled back
|
||||
// on error. A ref guard ignores taps while a request is in flight.
|
||||
async function onLikeToggle() {
|
||||
if (likeBusy.current) return;
|
||||
likeBusy.current = true;
|
||||
const next = !liked;
|
||||
setLiked(next);
|
||||
setLikeCount((c) => Math.max(0, c + (next ? 1 : -1)));
|
||||
try {
|
||||
const res = next
|
||||
? await postsApi.like(post.id)
|
||||
: await postsApi.unlike(post.id);
|
||||
setLiked(res.liked);
|
||||
setLikeCount(res.likes_count);
|
||||
} catch (err) {
|
||||
setLiked(!next);
|
||||
setLikeCount((c) => Math.max(0, c + (next ? -1 : 1)));
|
||||
const msg =
|
||||
err instanceof SocialError ? err.message : "Could not update like";
|
||||
toast.error(msg, { duration: 3000 });
|
||||
} finally {
|
||||
likeBusy.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onReshare() {
|
||||
// Optimistic flip first so the UI feels instant; revert on error.
|
||||
setReshared(true);
|
||||
try {
|
||||
await postsApi.reshare(post.id);
|
||||
toast.success("Reshared to your account", { duration: 2000 });
|
||||
} catch (err) {
|
||||
setReshared(false);
|
||||
const msg =
|
||||
err instanceof SocialError ? err.message : "Could not reshare";
|
||||
toast.error(msg, { duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Send button: hand off to the provider, which decides between the
|
||||
// mobile native-share path (await short URL + image proxy in
|
||||
// parallel, then navigator.share) and the desktop/no-native-share
|
||||
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
||||
// is always-resolves, so a single try/finally toggles the spinner.
|
||||
async function onSendClick() {
|
||||
if (sharePending) return;
|
||||
setSharePending(true);
|
||||
try {
|
||||
await openShareSheet(post);
|
||||
} finally {
|
||||
setSharePending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ActionButton
|
||||
label={liked ? "Unlike" : "Like"}
|
||||
onClick={() => withAuth(onLikeToggle)}
|
||||
icon={
|
||||
liked ? (
|
||||
<HiHeart className={cn(ICON_CLASS, "text-red-500")} />
|
||||
) : (
|
||||
<HiOutlineHeart className={ICON_CLASS} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{likeCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLikesOpen(true)}
|
||||
aria-label={`${likeCount} ${likeCount === 1 ? "like" : "likes"}`}
|
||||
className="-ml-1 mr-1 rounded px-1 text-sm font-medium tabular-nums text-muted-foreground transition-colors hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{likeCount}
|
||||
</button>
|
||||
) : null}
|
||||
<ActionButton
|
||||
label="Comment"
|
||||
onClick={() => setCommentsOpen(true)}
|
||||
icon={<HiOutlineChatBubbleOvalLeft className={ICON_CLASS} />}
|
||||
/>
|
||||
{commentCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommentsOpen(true)}
|
||||
aria-label={`${commentCount} ${commentCount === 1 ? "comment" : "comments"}`}
|
||||
className="-ml-2 mr-1 rounded px-1 text-sm font-medium tabular-nums text-muted-foreground transition-colors hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{commentCount}
|
||||
</button>
|
||||
) : null}
|
||||
{/* Reshare for posts not yet on your account, Send for posts
|
||||
that are — authored OR previously reshared. Mutually
|
||||
exclusive: the row stays three icons regardless of viewer
|
||||
state. */}
|
||||
{showSend ? (
|
||||
<ActionButton
|
||||
label={sharePending ? "Preparing share link…" : "Send"}
|
||||
onClick={onSendClick}
|
||||
disabled={sharePending}
|
||||
icon={
|
||||
sharePending ? (
|
||||
<Loader2
|
||||
className={cn(ICON_CLASS, "animate-spin")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
// -rotate-45 CCW takes the default east-pointing
|
||||
// airplane and tilts it toward the upper-right, which
|
||||
// reads more as "send / off it goes" than the
|
||||
// horizontal default.
|
||||
<HiOutlinePaperAirplane
|
||||
className={cn(ICON_CLASS, "-rotate-45")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Reshare"
|
||||
onClick={() => withAuth(onReshare)}
|
||||
icon={<HiOutlineArrowPath className={ICON_CLASS} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ActionButton
|
||||
label="Save to collection"
|
||||
onClick={() => withAuth(() => setCollectionsOpen(true))}
|
||||
icon={<HiOutlineFolderPlus className={ICON_CLASS} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pinned comment — stays visible under the action row even with the
|
||||
comments drawer closed. Tapping it opens the thread. */}
|
||||
{pinned ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommentsOpen(true)}
|
||||
className="mx-2 mt-1 flex w-[calc(100%-1rem)] items-center gap-1.5 rounded-lg bg-muted/50 px-3 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Pin className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="font-medium text-foreground shrink-0">
|
||||
@{pinned.author_handle}
|
||||
</span>
|
||||
<span className="truncate">{pinned.body}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<CollectionsDrawer
|
||||
open={collectionsOpen}
|
||||
onClose={() => setCollectionsOpen(false)}
|
||||
isMobile={isMobile}
|
||||
existingPostId={post.id}
|
||||
/>
|
||||
<PostLikesDrawer
|
||||
open={likesOpen}
|
||||
postId={post.id}
|
||||
onClose={() => setLikesOpen(false)}
|
||||
/>
|
||||
<PostCommentsDrawer
|
||||
open={commentsOpen}
|
||||
post={post}
|
||||
onClose={() => setCommentsOpen(false)}
|
||||
onPinnedChange={setPinned}
|
||||
onCountChange={(delta) =>
|
||||
setCommentCount((c) => Math.max(0, c + delta))
|
||||
}
|
||||
/>
|
||||
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
||||
no per-card instance lives here. The provider's
|
||||
openShareSheet swaps the active post under the hood. */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
/** Greys out the button and blocks clicks. Used by the Send button
|
||||
* while the short URL is being resolved on the mobile native-share
|
||||
* path so users can't fire navigator.share before the URL is
|
||||
* ready. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"size-10 inline-flex items-center justify-center rounded-full",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user