chore: code refactor

This commit is contained in:
gosangam
2026-06-13 02:24:42 +05:30
parent cc5633c37a
commit 439664a97d
151 changed files with 1387 additions and 1138 deletions
@@ -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>
);
}
+363
View File
@@ -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>
);
}
+145
View File
@@ -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",
};
}
+151
View File
@@ -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>
);
}
+159
View File
@@ -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)}
/>
);
}
+372
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}