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:
parent
27125047bb
commit
581fdb074d
2 changed files with 105 additions and 8 deletions
|
|
@ -3,6 +3,36 @@ import { sql } from "@/db";
|
|||
import { requireFamily } from "@/lib/auth";
|
||||
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() {
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ function PostCard({
|
|||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [reportSent, setReportSent] = 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 res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`);
|
||||
|
|
@ -76,10 +80,38 @@ function PostCard({
|
|||
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;
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-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>
|
||||
</div>
|
||||
</div>
|
||||
{/* ⋯ menu */}
|
||||
{/* ⋯ menu — rendered outside overflow:hidden so it doesn't get clipped */}
|
||||
<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 && (
|
||||
<>
|
||||
<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) && (
|
||||
<button
|
||||
onClick={() => { setShowMenu(false); setDeleteConfirm(true); }}
|
||||
|
|
@ -126,12 +164,40 @@ function PostCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>}
|
||||
{/* Body — inline edit or display */}
|
||||
{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 && (
|
||||
<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 */}
|
||||
|
|
@ -192,6 +258,7 @@ function PostCard({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue