fix: post menu clipping, add edit post, fix image display + fullscreen

- Remove overflow-hidden from PostCard root so the ⋯ dropdown menu
  is no longer clipped by the card boundary
- Add Edit Post option to menu (own posts only) with inline textarea
  and Save/Cancel; calls new PATCH /api/circles/[id]/posts/[postId]
- Add PATCH endpoint: author-only text edit
- Fix image display: object-contain (no crop) instead of object-cover
- Add tap-to-fullscreen lightbox: clicking any post image opens a
  full-screen black overlay with the image at natural size, ✕ to close

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 07:56:10 +05:30
parent 27125047bb
commit 581fdb074d
2 changed files with 105 additions and 8 deletions

View file

@ -3,6 +3,36 @@ import { sql } from "@/db";
import { requireFamily } from "@/lib/auth"; import { requireFamily } from "@/lib/auth";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
// PATCH — author can edit the text body of their own post
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
try {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId, postId } = await params;
const [post] = await sql.unsafe(
`SELECT author_family_id as "authorFamilyId" FROM circle_posts WHERE id = $1 AND circle_id = $2`,
[postId, circleId]
);
if (!post) return NextResponse.json({ error: "Post not found" }, { status: 404 });
if (post.authorFamilyId !== familyId)
return NextResponse.json({ error: "Only the author can edit this post" }, { status: 403 });
const body = await req.json();
const newBody = (body.body ?? "").trim();
await sql.unsafe(`UPDATE circle_posts SET body = $1 WHERE id = $2`, [newBody || null, postId]);
return NextResponse.json({ success: true });
} catch (err: unknown) {
return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 });
}
}
function makeR2Client() { function makeR2Client() {
return new S3Client({ return new S3Client({
region: "auto", region: "auto",

View file

@ -36,6 +36,10 @@ function PostCard({
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [reportSent, setReportSent] = useState(false); const [reportSent, setReportSent] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editBody, setEditBody] = useState(post.body ?? "");
const [savingEdit, setSavingEdit] = useState(false);
const [lightbox, setLightbox] = useState(false);
const loadComments = async () => { const loadComments = async () => {
const res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`); const res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`);
@ -76,10 +80,38 @@ function PostCard({
onDeleted(post.id); onDeleted(post.id);
}; };
const saveEdit = async () => {
setSavingEdit(true);
await fetch(`/api/circles/${circleId}/posts/${post.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: editBody }),
});
post.body = editBody; // optimistic local update
setEditMode(false);
setSavingEdit(false);
};
const isOwn = post.authorFamilyId === myFamilyId; const isOwn = post.authorFamilyId === myFamilyId;
return ( return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden"> <>
{/* Fullscreen image lightbox */}
{lightbox && post.imageUrl && (
<div
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
onClick={() => setLightbox(false)}
>
<img
src={post.imageUrl}
alt="Full size"
className="max-w-full max-h-full object-contain rounded-xl"
/>
<button className="absolute top-4 right-4 text-white text-2xl bg-black/40 rounded-full w-10 h-10 flex items-center justify-center"></button>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm">
{/* Author row */} {/* Author row */}
<div className="flex items-center justify-between px-4 pt-3 pb-2"> <div className="flex items-center justify-between px-4 pt-3 pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -89,13 +121,19 @@ function PostCard({
<p className="text-xs text-gray-400">{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}</p> <p className="text-xs text-gray-400">{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}</p>
</div> </div>
</div> </div>
{/* ⋯ menu */} {/* ⋯ menu — rendered outside overflow:hidden so it doesn't get clipped */}
<div className="relative"> <div className="relative">
<button onClick={() => setShowMenu(v => !v)} className="p-2 text-gray-400"></button> <button onClick={() => setShowMenu(v => !v)} className="p-2 text-gray-400 text-lg"></button>
{showMenu && ( {showMenu && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} /> <div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 top-8 z-50 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[160px] py-1"> <div className="absolute right-0 top-9 z-50 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[170px] py-1">
{isOwn && (
<button
onClick={() => { setShowMenu(false); setEditMode(true); setEditBody(post.body ?? ""); }}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
> Edit post</button>
)}
{(isOwn || isAdmin) && ( {(isOwn || isAdmin) && (
<button <button
onClick={() => { setShowMenu(false); setDeleteConfirm(true); }} onClick={() => { setShowMenu(false); setDeleteConfirm(true); }}
@ -126,12 +164,40 @@ function PostCard({
</div> </div>
)} )}
{/* Body */} {/* Body — inline edit or display */}
{post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>} {editMode ? (
<div className="px-4 pb-3 space-y-2">
<textarea
value={editBody}
onChange={e => setEditBody(e.target.value)}
rows={3}
autoFocus
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none border border-rose-300"
/>
<div className="flex gap-2">
<button
onClick={saveEdit}
disabled={savingEdit}
className="flex-1 py-1.5 bg-rose-400 text-white rounded-lg text-sm disabled:opacity-50"
>{savingEdit ? "Saving…" : "Save"}</button>
<button onClick={() => setEditMode(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
</div>
</div>
) : (
post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>
)}
{/* Image */} {/* Image — contain (no crop) + tap for fullscreen */}
{post.imageUrl && ( {post.imageUrl && (
<img src={post.imageUrl} alt="Post" className="w-full max-h-80 object-cover" /> <div className="overflow-hidden rounded-none mx-0 mb-0">
<img
src={post.imageUrl}
alt="Post"
onClick={() => setLightbox(true)}
className="w-full max-h-80 object-contain bg-gray-50 dark:bg-gray-900 cursor-pointer"
/>
<p className="text-center text-xs text-gray-400 py-1">Tap to view full size</p>
</div>
)} )}
{/* Reactions */} {/* Reactions */}
@ -192,6 +258,7 @@ function PostCard({
</div> </div>
)} )}
</div> </div>
</>
); );
} }