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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostCardMenu — three-dot overflow menu shown in the post header.
|
||||
*
|
||||
* Items rendered are decided per viewer:
|
||||
*
|
||||
* • Author (you wrote the post) — only `Delete post`.
|
||||
* • Authenticated non-author — `Block @author` and `Report post`.
|
||||
* • Anonymous non-author — `Report post` only.
|
||||
*
|
||||
* The author never sees Block / Report on their own post (you can't
|
||||
* block yourself, and self-report doesn't make sense). Block + Report
|
||||
* are placeholder coming-soon toasts today; the slot is wired up so
|
||||
* shipping the actual flows is a one-line swap each.
|
||||
*
|
||||
* Trigger renders a dropdown on desktop and a bottom drawer on mobile,
|
||||
* matching the SharePostSheet / CollectionsDrawer convention used
|
||||
* elsewhere in the post action UI.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Ban, Copy, Flag, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
posts,
|
||||
relationships,
|
||||
type Post,
|
||||
type PostReportCategory,
|
||||
} 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 { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||
import { ReportDialog, type ReportOption } from "@/components/social/report-dialog";
|
||||
import { createShortLink } from "@/lib/share";
|
||||
import { structuredReferenceForPost } from "@/lib/source-reference";
|
||||
|
||||
// Post report categories — mirrors the BFF allow-list (postReportCategories).
|
||||
const POST_REPORT_OPTIONS: ReportOption[] = [
|
||||
{ value: "spam", label: "Spam or misleading" },
|
||||
{ value: "harassment", label: "Harassment or abuse" },
|
||||
{ value: "misinformation", label: "False information" },
|
||||
{ value: "inappropriate", label: "Inappropriate content" },
|
||||
{ value: "other", label: "Something else" },
|
||||
];
|
||||
|
||||
type MenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
export function PostCardMenu({
|
||||
post,
|
||||
onDelete,
|
||||
}: {
|
||||
post: Post;
|
||||
/** Owner-only: when provided AND the viewer is the author, the menu
|
||||
* exposes a Delete option. Parent receives the post id and is
|
||||
* expected to confirm + invoke the delete itself (matches the
|
||||
* existing /u/me Delete-with-modal pattern). */
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const { user, loading } = useAuth();
|
||||
const { requireAuth } = useAuthGate();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [blockOpen, setBlockOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [popPos, setPopPos] = useState<{ top: number; left: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isOwner = !loading && !!user && user.id === post.user_id;
|
||||
const isAuthed = !loading && !!user;
|
||||
const authorHandle = post.author_handle ?? null;
|
||||
const authorDisplay = authorHandle
|
||||
? `@${authorHandle}`
|
||||
: post.author_name || "this user";
|
||||
|
||||
// Desktop popover positioning: anchor below the trigger, right-aligned.
|
||||
// We measure in event handlers (the open click + resize/scroll), never
|
||||
// synchronously inside an effect body, so opening can't trigger a
|
||||
// cascading render. The effect below only wires the resize/scroll
|
||||
// listeners while the popover is open.
|
||||
const recompute = useCallback(() => {
|
||||
const btn = triggerRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const popoverW = 240;
|
||||
const pad = 8;
|
||||
// Right-align the popover to the trigger's right edge — feels natural
|
||||
// for an overflow menu sitting in the top-right corner.
|
||||
let left = rect.right - popoverW;
|
||||
if (left < pad) left = pad;
|
||||
if (left + popoverW > window.innerWidth - pad) {
|
||||
left = window.innerWidth - popoverW - pad;
|
||||
}
|
||||
setPopPos({ top: rect.bottom + 4, left });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
window.addEventListener("resize", recompute);
|
||||
window.addEventListener("scroll", recompute, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", recompute);
|
||||
window.removeEventListener("scroll", recompute, true);
|
||||
};
|
||||
}, [open, isMobile, recompute]);
|
||||
|
||||
// Click-outside + Escape to close (desktop only; the drawer's own
|
||||
// overlay handles dismissal on mobile).
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return;
|
||||
function onPointer(e: PointerEvent) {
|
||||
const popover = popoverRef.current;
|
||||
const trigger = triggerRef.current;
|
||||
if (popover && popover.contains(e.target as Node)) return;
|
||||
if (trigger && trigger.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
// Delay the listener install one tick so the click that opened us
|
||||
// doesn't immediately close us in the same task.
|
||||
const t = setTimeout(() => {
|
||||
document.addEventListener("pointerdown", onPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
}, 0);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
document.removeEventListener("pointerdown", onPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, isMobile]);
|
||||
|
||||
// Copy the post's caption + source citation + a (short) link to the post —
|
||||
// the same payload the share sheet / footer produce. Available to every
|
||||
// viewer.
|
||||
async function copyCaption() {
|
||||
setOpen(false);
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
let url = "";
|
||||
if (authorHandle) {
|
||||
const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`;
|
||||
const code = await createShortLink(longPath);
|
||||
url = code
|
||||
? new URL(`/share/${code}`, origin).toString()
|
||||
: new URL(longPath, origin).toString();
|
||||
}
|
||||
const ref = structuredReferenceForPost(post.source_url, post.source_title);
|
||||
const lines: string[] = [];
|
||||
if (post.caption) lines.push(`“${post.caption}”`, "");
|
||||
lines.push("Source:", ref.title);
|
||||
if (ref.detail) lines.push(ref.detail);
|
||||
if (url) lines.push(url);
|
||||
await navigator.clipboard.writeText(lines.join("\n"));
|
||||
toast.success("Caption copied");
|
||||
} catch {
|
||||
toast.error("Could not copy");
|
||||
}
|
||||
}
|
||||
|
||||
// Build the items list AFTER all hooks have run so a conditional
|
||||
// early-return doesn't break the rules-of-hooks contract on
|
||||
// re-renders where viewer state changes.
|
||||
const items: MenuItem[] = [];
|
||||
// Copy caption — first item for everyone (owner and non-owner alike).
|
||||
items.push({
|
||||
key: "copy",
|
||||
label: "Copy caption",
|
||||
icon: <Copy className="size-4" />,
|
||||
onClick: copyCaption,
|
||||
});
|
||||
if (isOwner) {
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
key: "delete",
|
||||
label: "Delete post",
|
||||
icon: <Trash2 className="size-4" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
onDelete(post.id);
|
||||
},
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Block needs the author's handle (the block API is keyed by handle).
|
||||
if (isAuthed && authorHandle) {
|
||||
items.push({
|
||||
key: "block",
|
||||
label: `Block ${authorDisplay}`,
|
||||
icon: <Ban className="size-4" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
setBlockOpen(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
key: "report",
|
||||
label: "Report post",
|
||||
icon: <Flag className="size-4" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
// Reporting requires auth (the BFF route is protected). Gate
|
||||
// anonymous viewers through the login modal, then open the form.
|
||||
void (async () => {
|
||||
if (await requireAuth()) setReportOpen(true);
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Don't render the trigger when there's literally nothing to click —
|
||||
// e.g. an owner-viewer on a surface that doesn't grant delete.
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Measure synchronously in the click (an event handler — safe) so
|
||||
// the popover renders at the right spot on first paint.
|
||||
if (!open && !isMobile) recompute();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label="Post options"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"size-8 inline-flex items-center justify-center rounded-full shrink-0",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
// Menu surface: bottom drawer on mobile, floating popover on desktop.
|
||||
const menu = isMobile ? (
|
||||
<Drawer open={open} onOpenChange={(o) => setOpen(o)}>
|
||||
<DrawerContent className="bg-card select-none">
|
||||
<DrawerHeader className="text-left border-b border-border pb-3">
|
||||
<DrawerTitle>Post options</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="p-3 space-y-1">
|
||||
{items.map((item) => (
|
||||
<MenuItemButton key={item.key} item={item} mobile />
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : open && popPos ? (
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: popPos.top,
|
||||
left: popPos.left,
|
||||
zIndex: 60,
|
||||
}}
|
||||
className={cn(
|
||||
"w-60 rounded-lg border border-border bg-card shadow-lg py-1 select-none",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<MenuItemButton key={item.key} item={item} />
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{menu}
|
||||
|
||||
{/* Block confirm — only meaningful when we know the author's handle. */}
|
||||
{authorHandle ? (
|
||||
<ConfirmModal
|
||||
open={blockOpen}
|
||||
onClose={() => setBlockOpen(false)}
|
||||
title={`Block ${authorDisplay}?`}
|
||||
message="They won't be able to follow you or see that you follow them, and any follow between you will be removed. You can unblock them later."
|
||||
confirmLabel="Block"
|
||||
onConfirm={async () => {
|
||||
await relationships.block(authorHandle);
|
||||
toast(`Blocked ${authorDisplay}`, { duration: 2500 });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ReportDialog
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
title="Report post"
|
||||
options={POST_REPORT_OPTIONS}
|
||||
onSubmit={async (category, reason) => {
|
||||
await posts.report(post.id, category as PostReportCategory, reason);
|
||||
toast("Thanks — your report was submitted", { duration: 3000 });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItemButton({
|
||||
item,
|
||||
mobile = false,
|
||||
}: {
|
||||
item: MenuItem;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const destructive = item.variant === "destructive";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 text-left transition-colors rounded-md",
|
||||
mobile ? "px-3 py-3" : "px-3 py-2",
|
||||
destructive
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
destructive ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Helpers shared by PostGridCard and PostScrollCard.
|
||||
*
|
||||
* • postSourceHref — synthesizes `?hl=` if the stored URL lacks one
|
||||
* • themeGradientStyle — picks one of four palette-aware gradients
|
||||
* deterministically from a seed (post id) so a
|
||||
* feed of image-less posts looks varied without
|
||||
* shifting between renders.
|
||||
*/
|
||||
|
||||
import type { Post } from "@/lib/social";
|
||||
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
|
||||
|
||||
export function postSourceHref(post: Post): string {
|
||||
return hasHighlight(post.source_url) || !post.caption
|
||||
? post.source_url
|
||||
: appendHighlight(post.source_url, post.caption);
|
||||
}
|
||||
|
||||
// Quote-card gradients.
|
||||
//
|
||||
// Four CSS custom properties (`--quote-gradient-1` … 4) are defined per
|
||||
// palette in `app/globals.css`. Each palette ships its own hand-tuned
|
||||
// gradient set so the cards swap colour with the active theme:
|
||||
//
|
||||
// • Aged Parchment → mahogany binding + temple gold
|
||||
// • Tulasī Garden → peacock depths + temple gold
|
||||
// • Yamunā Mist → river dusk navy + sandbank
|
||||
// • Ancient Manuscript → midnight leather + saffron
|
||||
// • Nīlamaṇi → peacock night over still water
|
||||
// • Vraja Twilight → deep wine + rose dusk
|
||||
//
|
||||
// All variants are dark-leaning so `text-white` reads cleanly. The card
|
||||
// picks one of the four deterministically from `post.id`.
|
||||
const GRADIENT_VARS = [
|
||||
"var(--quote-gradient-1)",
|
||||
"var(--quote-gradient-2)",
|
||||
"var(--quote-gradient-3)",
|
||||
"var(--quote-gradient-4)",
|
||||
];
|
||||
|
||||
/** Returns an inline-style object that applies the chosen gradient. The
|
||||
* style approach (vs a className) lets us reference CSS custom properties
|
||||
* that vary per palette — Tailwind can't generate utility classes for
|
||||
* values that change at runtime via `:root` selectors. */
|
||||
export function themeGradientStyle(seed: string): React.CSSProperties {
|
||||
// Cheap, stable hash — same seed always picks the same gradient.
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return { backgroundImage: GRADIENT_VARS[h % GRADIENT_VARS.length] };
|
||||
}
|
||||
|
||||
/** Flat background for post cards without an image: pure white on every
|
||||
* light palette, palette-specific darker contrast on dark palettes.
|
||||
* Defined as `--quote-card-bg` per `:root` / `.dark` block in
|
||||
* globals.css. Combine with `text-foreground` for legibility — both
|
||||
* sides of the theme switch reverse together. */
|
||||
export function quoteCardBgStyle(): React.CSSProperties {
|
||||
return { backgroundColor: "var(--quote-card-bg)" };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Variable quote sizing
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A pithy two-word quote and a full paragraph need very different
|
||||
// treatments inside a fixed-size card. We pick a tier from the caption's
|
||||
// character count:
|
||||
//
|
||||
// tier 1 (very short) — biggest font, dead-centre, no truncation needed
|
||||
// tier 2 (short) — large font, centred, ~5-line clamp
|
||||
// tier 3 (medium) — medium font, centred, ~6/7-line clamp
|
||||
// tier 4 (long) — smallest font, anchored bottom-LEFT and left-
|
||||
// aligned (a centred wall of small text would
|
||||
// cover the entire card and look claustrophobic;
|
||||
// bottom-left mimics the image variant's caption
|
||||
// placement and breathes better).
|
||||
//
|
||||
// Two scales: "grid" for the small square card, "scroll" for the larger
|
||||
// Instagram-style card.
|
||||
|
||||
export type QuoteSize = {
|
||||
/** Tailwind font-size classes, e.g. "text-xl sm:text-2xl" */
|
||||
fontClass: string;
|
||||
/** Tailwind line-clamp class limiting overflow */
|
||||
clampClass: string;
|
||||
/** Where to anchor the text block inside its container */
|
||||
alignment: "center" | "bottom-left";
|
||||
};
|
||||
|
||||
export function quoteSize(text: string, variant: "grid" | "scroll"): QuoteSize {
|
||||
const len = (text ?? "").trim().length;
|
||||
if (variant === "grid") {
|
||||
if (len <= 50)
|
||||
return {
|
||||
fontClass: "text-xl sm:text-2xl",
|
||||
clampClass: "line-clamp-3",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 150)
|
||||
return {
|
||||
fontClass: "text-base sm:text-lg",
|
||||
clampClass: "line-clamp-5",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 300)
|
||||
return {
|
||||
fontClass: "text-sm sm:text-base",
|
||||
clampClass: "line-clamp-[7]",
|
||||
alignment: "center",
|
||||
};
|
||||
return {
|
||||
fontClass: "text-xs sm:text-sm",
|
||||
clampClass: "line-clamp-[9]",
|
||||
alignment: "bottom-left",
|
||||
};
|
||||
}
|
||||
// scroll — bigger card, larger fonts at every tier, higher thresholds
|
||||
// so paragraph-length quotes still get centred at a comfortable size.
|
||||
if (len <= 60)
|
||||
return {
|
||||
fontClass: "text-3xl sm:text-4xl",
|
||||
clampClass: "line-clamp-3",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 180)
|
||||
return {
|
||||
fontClass: "text-2xl sm:text-3xl",
|
||||
clampClass: "line-clamp-5",
|
||||
alignment: "center",
|
||||
};
|
||||
if (len <= 380)
|
||||
return {
|
||||
fontClass: "text-lg sm:text-xl",
|
||||
clampClass: "line-clamp-[8]",
|
||||
alignment: "center",
|
||||
};
|
||||
return {
|
||||
fontClass: "text-base sm:text-lg",
|
||||
clampClass: "line-clamp-[10]",
|
||||
alignment: "bottom-left",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostCard — renders one post in the public feed and the /u/me feed.
|
||||
*
|
||||
* • Author header — avatar + display name + @handle on the left,
|
||||
* three-dot overflow menu on the right. Menu contents vary by
|
||||
* viewer (see PostCardMenu): author sees Delete; signed-in
|
||||
* non-author sees Block + Report; anonymous sees Report.
|
||||
* • Caption in serif (locked, can't be edited)
|
||||
* • Image grid (max 5; collapses to one column on the cheap)
|
||||
* • "Source" link back to the page the quote came from
|
||||
* • Optional onDelete callback — passed through to PostCardMenu so
|
||||
* Delete actually does something on owner views (parent confirms +
|
||||
* calls the API; same pattern as before the menu refactor).
|
||||
*
|
||||
* The source link always lands the user on the original passage with `?hl=`
|
||||
* highlighting. If the stored source_url already carries `?hl=` we use it
|
||||
* verbatim; otherwise we synthesise one from the post's caption so older
|
||||
* posts (or imported data) still scroll to + highlight the text on click.
|
||||
*/
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, Repeat2 } from "lucide-react";
|
||||
import type { Post } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
import { PostActionsFooter } from "./post-actions-footer";
|
||||
import { PostCardMenu } from "./post-card-menu";
|
||||
|
||||
export function PostCard({
|
||||
post,
|
||||
onDelete,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
}) {
|
||||
const sourceHref =
|
||||
hasHighlight(post.source_url) || !post.caption
|
||||
? post.source_url
|
||||
: appendHighlight(post.source_url, post.caption);
|
||||
|
||||
const authorName = post.author_name || "Unknown";
|
||||
const authorHandle = post.author_handle ?? null;
|
||||
|
||||
return (
|
||||
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5 select-none">
|
||||
{/* Reshare attribution — only on rows that are themselves
|
||||
reshares. The link goes to the original author's profile;
|
||||
their handle is rendered inline so the source is obvious.
|
||||
The post body below shows the resharer as the author (this
|
||||
IS their post — likes, comments, and background all belong
|
||||
to them, independent from the original). */}
|
||||
{post.reshared_from_post_id && post.original_author_handle ? (
|
||||
<Link
|
||||
href={`/u/${post.original_author_handle}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground mb-3 transition-colors"
|
||||
>
|
||||
<Repeat2 className="size-3.5" />
|
||||
<span>
|
||||
Reshared from{" "}
|
||||
<span className="font-medium">@{post.original_author_handle}</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
{/* Author header — identity on the left, overflow menu on the
|
||||
right. The menu trigger only renders when there are items to
|
||||
show (delete for owners, block/report for everyone else), so
|
||||
a view with nothing to expose simply hides the dot. */}
|
||||
<header className="flex items-center gap-3 mb-4">
|
||||
<UserAvatar
|
||||
src={post.author_avatar_url}
|
||||
name={authorName}
|
||||
className="size-9"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{authorHandle ? (
|
||||
<Link
|
||||
href={`/u/${authorHandle}`}
|
||||
className="block text-sm font-semibold text-foreground truncate hover:underline"
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-foreground truncate">
|
||||
{authorName}
|
||||
</p>
|
||||
)}
|
||||
{authorHandle ? (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
@{authorHandle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<PostCardMenu post={post} onDelete={onDelete} />
|
||||
</header>
|
||||
|
||||
<p className="font-[family-name:var(--font-title)] text-lg text-foreground leading-relaxed">
|
||||
“{post.caption}”
|
||||
</p>
|
||||
|
||||
{post.images && post.images.length > 0 ? (
|
||||
<div
|
||||
className={`mt-4 grid gap-2 ${
|
||||
post.images.length === 1
|
||||
? "grid-cols-1"
|
||||
: post.images.length === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{post.images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 30vw, 200px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={sourceHref}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{displaySourceLabel(post.source_url, post.source_title)}
|
||||
</Link>
|
||||
<span>{new Date(post.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="mt-3 pt-2 border-t border-border">
|
||||
<PostActionsFooter post={post} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostGridCard — square card used in the 2-per-row grid view.
|
||||
*
|
||||
* • With image: first attached image fills the card; a bottom-up dark
|
||||
* gradient makes the caption legible without dropping the image's
|
||||
* atmosphere.
|
||||
* • Without image: a palette-aware Tailwind gradient stands in; the
|
||||
* caption sits as a serif quote bottom-left in the foreground colour.
|
||||
*
|
||||
* `onDelete` shows a small X in the corner for owner views. Used both for
|
||||
* actual post deletion (on /u/me) and for removing from a collection
|
||||
* (on /u/me/collections/[id]); the parent decides which.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ImageIcon, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { postSourceHref, quoteCardBgStyle, quoteSize } from "./post-card-shared";
|
||||
import { UpdateBackgroundModal } from "../update-background-modal";
|
||||
|
||||
export function PostGridCard({
|
||||
post,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
href: hrefProp,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
/** Owner-only: shown when defined so the user can swap/remove the
|
||||
* card's background image. */
|
||||
onUpdated?: (updated: Post) => void;
|
||||
/** Override the click target. Defaults to the source-passage URL.
|
||||
* Profile grids pass a `/u/<handle>/posts#post-<id>` anchor so the
|
||||
* thumbnail jumps to the Instagram-style scroll feed instead of the
|
||||
* knowledgebase page. */
|
||||
href?: string;
|
||||
}) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [bgModal, setBgModal] = useState(false);
|
||||
const firstImage = post.images?.[0]?.url;
|
||||
const href = hrefProp ?? postSourceHref(post);
|
||||
const size = quoteSize(post.caption, "grid");
|
||||
// Image variant always anchors text bottom-left over the image's dark
|
||||
// overlay. No-image variant lets `size.alignment` decide — short quotes
|
||||
// sit centred so the gradient frames them; long quotes fall back to
|
||||
// bottom-left so they don't cover the whole card.
|
||||
const bottomLeft = !!firstImage || size.alignment === "bottom-left";
|
||||
|
||||
return (
|
||||
<div className="relative group select-none">
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10",
|
||||
"hover:ring-foreground/25 hover:shadow-md transition-all relative",
|
||||
)}
|
||||
>
|
||||
{firstImage ? (
|
||||
<>
|
||||
<Image
|
||||
src={firstImage}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, 320px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
{/* Full-coverage scrim — even the top of the card carries enough
|
||||
darkness (~40%) so white text overlaid anywhere stays
|
||||
legible without looking blended with the image. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/70 to-black/40" />
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={quoteCardBgStyle()} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col p-4 sm:p-5",
|
||||
// Text color: white on the dark image overlay, theme
|
||||
// foreground on the flat quote-card background.
|
||||
firstImage ? "text-white" : "text-foreground",
|
||||
bottomLeft
|
||||
? "justify-end items-start text-left"
|
||||
: "justify-center items-center text-center",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] leading-snug break-words",
|
||||
size.fontClass,
|
||||
size.clampClass,
|
||||
// Drop-shadow only helps when text sits on imagery; on the
|
||||
// flat bg it just blurs the type.
|
||||
firstImage && "drop-shadow-sm",
|
||||
// Centred quotes need a soft horizontal cap so a long line
|
||||
// doesn't kiss the card edge — looks better with margins.
|
||||
!bottomLeft ? "max-w-[90%]" : "w-full",
|
||||
)}
|
||||
>
|
||||
“{post.caption}”
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-[0.65rem] uppercase tracking-[0.2em] font-medium truncate",
|
||||
firstImage ? "text-white/80" : "text-muted-foreground",
|
||||
bottomLeft ? "w-full" : "max-w-[90%]",
|
||||
)}
|
||||
>
|
||||
{displaySourceLabel(post.source_url, post.source_title)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{onUpdated || onDelete ? (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
{onUpdated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgModal(true)}
|
||||
aria-label="Update background"
|
||||
title="Update background"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(post.id)}
|
||||
aria-label="Remove"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onUpdated ? (
|
||||
<UpdateBackgroundModal
|
||||
open={bgModal}
|
||||
onClose={() => setBgModal(false)}
|
||||
post={post}
|
||||
isMobile={isMobile}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostLikesDrawer — the "liked by" list for a post. A thin wrapper over the
|
||||
* generic virtualized UserListDrawer, wired to the post-likes endpoint.
|
||||
*/
|
||||
|
||||
import { posts } from "@/lib/social";
|
||||
import { UserListDrawer } from "@/components/social/user-list-drawer";
|
||||
|
||||
export function PostLikesDrawer({
|
||||
open,
|
||||
postId,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
postId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UserListDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Likes"
|
||||
depsKey={`likes:${postId}`}
|
||||
fetchPage={(params) => posts.likers(postId, params)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostScrollCard — Instagram-style post used on the scroll feed.
|
||||
*
|
||||
* Layout:
|
||||
*
|
||||
* ┌───────────────────────────────────────┐
|
||||
* │ ◯ name @handle ⋮ │ ← author header (TBD)
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ │
|
||||
* │ image carousel │ ← square, swipeable
|
||||
* │ (or quote) │ (no-image variant
|
||||
* │ │ fills with gradient
|
||||
* │ • ○ ○ ○ │ panel + serif quote)
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ "Quoted passage shown as caption…" │ ← truncates with "more"
|
||||
* │ ŚRĪMAD-BHĀGAVATAM — CANTO 4, CH 5 │ ← citation
|
||||
* └───────────────────────────────────────┘
|
||||
*
|
||||
* • Multi-image carousel uses native CSS scroll-snap with dot indicators
|
||||
* below; the same primitive CollectionScrollCard uses for posts.
|
||||
* • Long captions clamp to 3 lines until the user expands via "more".
|
||||
* • Owner action cluster (Update background / Delete) sits in the
|
||||
* top-right of the media area, fading in on hover (desktop) and
|
||||
* always visible on touch (mobile).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AtSign, ImageIcon, Repeat2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post, PostImage } from "@/lib/social";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
||||
import { PostActionsFooter } from "./post-actions-footer";
|
||||
import { PostCardMenu } from "./post-card-menu";
|
||||
import { UpdateBackgroundModal } from "../update-background-modal";
|
||||
import { MentionsDrawer } from "../mentions-drawer";
|
||||
|
||||
export function PostScrollCard({
|
||||
post,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdated?: (updated: Post) => void;
|
||||
}) {
|
||||
const images = post.images ?? [];
|
||||
const hasImages = images.length > 0;
|
||||
const sourceHref = postSourceHref(post);
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const { user } = useAuth();
|
||||
const isAuthor = !!user && user.id === post.user_id;
|
||||
const [bgModal, setBgModal] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [mentionsOpen, setMentionsOpen] = useState(false);
|
||||
// Live mention count — seeded from the hydrated post, adjusted when the
|
||||
// author edits tags in the drawer, so the icon appears/disappears in place.
|
||||
const [mentionCount, setMentionCount] = useState(post.mentions_count ?? 0);
|
||||
|
||||
// The author always gets the mentions entry point (to add/remove); everyone
|
||||
// else sees it only when the post actually tags someone.
|
||||
const showMentions = mentionCount > 0 || isAuthor;
|
||||
|
||||
// Top-right media overlay: a Mentions icon and the owner-only
|
||||
// Update-background button. Delete lives in the header menu.
|
||||
const topRight =
|
||||
showMentions || onUpdated ? (
|
||||
<div className="absolute top-3 right-3 z-10 flex items-center gap-2">
|
||||
{showMentions ? (
|
||||
<OwnerButton
|
||||
label="See who's mentioned"
|
||||
onClick={() => setMentionsOpen(true)}
|
||||
icon={<AtSign className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
{onUpdated ? (
|
||||
<OwnerButton
|
||||
label="Update background"
|
||||
onClick={() => setBgModal(true)}
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`post-${post.id}`}
|
||||
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24 select-none"
|
||||
>
|
||||
{/* Reshare attribution — only on rows that are themselves
|
||||
reshares. Tappable link → original author's profile. The card
|
||||
body below shows the resharer as the author (this row IS the
|
||||
resharer's own post — likes, comments, background all belong
|
||||
to them, decoupled from the original). */}
|
||||
{post.reshared_from_post_id && post.original_author_handle ? (
|
||||
<Link
|
||||
href={`/u/${post.original_author_handle}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Repeat2 className="size-3.5" />
|
||||
<span>
|
||||
Reshared from{" "}
|
||||
<span className="font-medium">@{post.original_author_handle}</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
{/* Author header — identity on the left, three-dot overflow menu
|
||||
on the right (Delete for author, Block/Report for everyone
|
||||
else; PostCardMenu hides itself when there are no items). */}
|
||||
<header className="flex items-center gap-3 px-4 py-3">
|
||||
<UserAvatar
|
||||
src={post.author_avatar_url}
|
||||
name={post.author_name ?? "Unknown"}
|
||||
className="size-9"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={
|
||||
post.author_handle ? `/u/${post.author_handle}` : "#"
|
||||
}
|
||||
className="block text-sm font-semibold text-foreground truncate hover:underline"
|
||||
>
|
||||
{post.author_name || "Unknown"}
|
||||
</Link>
|
||||
{post.author_handle ? (
|
||||
<span className="block text-xs text-muted-foreground truncate">
|
||||
@{post.author_handle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<PostCardMenu post={post} onDelete={onDelete} />
|
||||
</header>
|
||||
|
||||
{/* Media — image carousel or gradient-with-quote */}
|
||||
<div className="relative">
|
||||
{hasImages ? (
|
||||
<ImageCarousel images={images} />
|
||||
) : (
|
||||
<QuotePanel post={post} />
|
||||
)}
|
||||
{topRight}
|
||||
</div>
|
||||
|
||||
{/* Caption + citation — reference sits directly under the media
|
||||
so it's read first, with the action row below it. */}
|
||||
<div className="px-4 pt-4 pb-3 space-y-2">
|
||||
{hasImages ? (
|
||||
// When images carry the visual, the caption sits as text below.
|
||||
<Caption
|
||||
text={post.caption}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded((v) => !v)}
|
||||
/>
|
||||
) : null}
|
||||
<Link
|
||||
href={sourceHref}
|
||||
className="block max-w-full truncate text-[0.7rem] uppercase tracking-[0.2em] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{displaySourceLabel(post.source_url, post.source_title)} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="pt-1 pb-2 border-t border-border">
|
||||
<PostActionsFooter post={post} />
|
||||
</div>
|
||||
|
||||
{onUpdated ? (
|
||||
<UpdateBackgroundModal
|
||||
open={bgModal}
|
||||
onClose={() => setBgModal(false)}
|
||||
post={post}
|
||||
isMobile={isMobile}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MentionsDrawer
|
||||
open={mentionsOpen}
|
||||
postId={post.id}
|
||||
authorId={post.user_id}
|
||||
onClose={() => setMentionsOpen(false)}
|
||||
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pieces ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ImageCarousel({ images }: { images: PostImage[] }) {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||
const el = e.currentTarget;
|
||||
const w = el.offsetWidth;
|
||||
if (w === 0) return;
|
||||
setActive(Math.round(el.scrollLeft / w));
|
||||
}
|
||||
|
||||
if (images.length === 1) {
|
||||
return (
|
||||
<div className="relative aspect-square bg-muted">
|
||||
<Image
|
||||
src={images[0].url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={onScroll}
|
||||
className={cn(
|
||||
"flex overflow-x-auto snap-x snap-mandatory",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${i + 1} of ${images.length}`}
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 640px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
|
||||
{images.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-1.5 rounded-full transition-all",
|
||||
i === active ? "bg-white" : "bg-white/50",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotePanel({ post }: { post: Post }) {
|
||||
// No-image post: the entire square is a flat palette-aware surface
|
||||
// (pure white on light themes, palette-specific dark on dark themes)
|
||||
// with the quote in serif inside. Text uses --foreground so it inverts
|
||||
// automatically with the theme.
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-square overflow-hidden select-none"
|
||||
style={quoteCardBgStyle()}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center p-8 sm:p-10 text-center text-foreground">
|
||||
<p className="font-[family-name:var(--font-title)] text-xl sm:text-2xl leading-relaxed max-w-md line-clamp-[8] break-words">
|
||||
“{post.caption}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Caption({
|
||||
text,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
text: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
// We clamp to 3 lines collapsed and expose a "More" toggle only when
|
||||
// the clamp is actually hiding something. A char-length heuristic gets
|
||||
// this wrong: a 200-char caption can fit cleanly in 3 lines on a wide
|
||||
// card (no clipping → "More" would be a false promise). Instead measure
|
||||
// scrollHeight vs clientHeight on the clamped <p>. Once we've observed
|
||||
// overflow we latch it on so "Show less" stays visible after expansion
|
||||
// (where scrollHeight would equal clientHeight and we'd otherwise lose
|
||||
// the way back).
|
||||
const pRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [overflowed, setOverflowed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) return;
|
||||
const el = pRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
// Sub-pixel rounding can leave a 1px gap even when content fits;
|
||||
// require a real difference before flagging overflow.
|
||||
setOverflowed(el.scrollHeight - el.clientHeight > 1);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [text, expanded]);
|
||||
|
||||
const showToggle = overflowed || expanded;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground leading-relaxed">
|
||||
<p
|
||||
ref={pRef}
|
||||
className={cn(
|
||||
"font-[family-name:var(--font-title)] break-words",
|
||||
!expanded && "line-clamp-3",
|
||||
)}
|
||||
>
|
||||
“{text}”
|
||||
</p>
|
||||
{showToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="mt-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? "Show less" : "More"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function OwnerButton({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"size-9 rounded-full inline-flex items-center justify-center",
|
||||
"bg-black/40 text-white hover:bg-black/60 transition-colors backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostViews — grid-only renderer for post lists.
|
||||
*
|
||||
* Renders a 3-up square grid (Instagram pattern). The previous
|
||||
* Grid/Scroll tab toggle is gone — the scroll feed lives at
|
||||
* /u/<handle>/posts and is reached by clicking a grid thumbnail
|
||||
* (which sets `?hl=` and a hash anchor).
|
||||
*
|
||||
* Callers pass `hrefFor(post)` if they want each thumbnail to link to a
|
||||
* specific URL (e.g. the scroll feed). When omitted, each card defaults
|
||||
* to its source-passage URL.
|
||||
*/
|
||||
|
||||
import type { Post } from "@/lib/social";
|
||||
import { PostGridCard } from "./post-grid-card";
|
||||
|
||||
export function PostViews({
|
||||
posts,
|
||||
onDelete,
|
||||
onUpdated,
|
||||
emptyMessage,
|
||||
hrefFor,
|
||||
}: {
|
||||
posts: Post[];
|
||||
onDelete?: (id: string) => void;
|
||||
onUpdated?: (updated: Post) => void;
|
||||
emptyMessage?: string;
|
||||
hrefFor?: (post: Post) => string;
|
||||
}) {
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{emptyMessage ?? "Nothing here yet."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
{posts.map((p) => (
|
||||
<PostGridCard
|
||||
key={p.id}
|
||||
post={p}
|
||||
onDelete={onDelete}
|
||||
onUpdated={onUpdated}
|
||||
href={hrefFor?.(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user