tia/src/app/circle/[id]/page.tsx
Mannu 66a765e75f fix: image upload in circle posts - handle empty content type + show errors
- Server: normalise empty/missing MIME type by sniffing file extension so
  iOS HEIC/HEIF and camera photos (which send empty type) are accepted
- Server: add image/heif and image/gif to allowed types
- Server: return normalised contentType in presign response
- Client: check presignRes.ok before uploading; use server contentType
  for the PUT to R2 so the header matches what was signed
- Client: show error message in modal instead of silent catch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:18:14 +05:30

597 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useCallback, use } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "../../FamilyProvider";
import type { CirclePost, CircleComment, Circle } from "@/types";
const REACTIONS = ["❤️", "😂", "👍", "🙏", "😮"];
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
// ── Post card ─────────────────────────────────────────────────────────────────
function PostCard({
post, myFamilyId, circleId, isAdmin,
onDeleted, onReact,
}: {
post: CirclePost;
myFamilyId: string;
circleId: string;
isAdmin: boolean;
onDeleted: (id: string) => void;
onReact: (postId: string, emoji: string) => void;
}) {
const [showComments, setShowComments] = useState(false);
const [comments, setComments] = useState<CircleComment[]>([]);
const [commentText, setCommentText] = useState("");
const [posting, setPosting] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [reportSent, setReportSent] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const loadComments = async () => {
const res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`);
const data = await res.json();
setComments(data.comments ?? []);
};
const toggleComments = () => {
if (!showComments) loadComments();
setShowComments(v => !v);
};
const addComment = async () => {
if (!commentText.trim()) return;
setPosting(true);
await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: commentText.trim() }),
});
setCommentText("");
loadComments();
setPosting(false);
};
const sendReport = async (reason: string) => {
await fetch(`/api/circles/${circleId}/posts/${post.id}/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
setReportSent(true);
setShowMenu(false);
};
const deletePost = async () => {
await fetch(`/api/circles/${circleId}/posts/${post.id}`, { method: "DELETE" });
onDeleted(post.id);
};
const isOwn = post.authorFamilyId === myFamilyId;
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
{/* Author row */}
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-sm">👨👩👧</div>
<div>
<p className="text-sm font-medium">{post.authorFamilyName}</p>
<p className="text-xs text-gray-400">{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}</p>
</div>
</div>
{/* ⋯ menu */}
<div className="relative">
<button onClick={() => setShowMenu(v => !v)} className="p-2 text-gray-400"></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">
{(isOwn || isAdmin) && (
<button
onClick={() => { setShowMenu(false); setDeleteConfirm(true); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
>🗑 Delete post</button>
)}
{!isOwn && !reportSent && (
<button
onClick={() => sendReport("inappropriate")}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
>🚩 Report post</button>
)}
{reportSent && <p className="px-4 py-2.5 text-xs text-gray-400">Reported thank you</p>}
</div>
</>
)}
</div>
</div>
{/* Delete confirmation */}
{deleteConfirm && (
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl space-y-2">
<p className="text-sm text-red-600 font-medium">Delete this post?</p>
<div className="flex gap-2">
<button onClick={deletePost} className="flex-1 py-1.5 bg-red-500 text-white rounded-lg text-sm">Delete</button>
<button onClick={() => setDeleteConfirm(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
</div>
</div>
)}
{/* Body */}
{post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>}
{/* Image */}
{post.imageUrl && (
<img src={post.imageUrl} alt="Post" className="w-full max-h-80 object-cover" />
)}
{/* Reactions */}
<div className="px-4 py-2 flex items-center gap-1 flex-wrap border-t border-gray-50 dark:border-gray-700">
{REACTIONS.map(emoji => {
const r = post.reactions.find(x => x.emoji === emoji);
return (
<button
key={emoji}
onClick={() => onReact(post.id, emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors ${
r?.includedMe
? "bg-rose-100 dark:bg-rose-900/40 text-rose-600"
: "bg-gray-50 dark:bg-gray-700 text-gray-500"
}`}
>
{emoji}{r?.count ? ` ${r.count}` : ""}
</button>
);
})}
<button
onClick={toggleComments}
className="ml-auto flex items-center gap-1 text-xs text-gray-400 px-2 py-1"
>
💬 {post.commentCount > 0 ? post.commentCount : ""} {showComments ? "▲" : "▼"}
</button>
</div>
{/* Comments */}
{showComments && (
<div className="border-t border-gray-50 dark:border-gray-700 px-4 py-3 space-y-3">
{comments.map(c => (
<div key={c.id} className="flex gap-2">
<div className="w-7 h-7 bg-rose-50 dark:bg-rose-900/30 rounded-full flex items-center justify-center text-xs flex-shrink-0">👨👩👧</div>
<div className="flex-1 bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2">
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">{c.authorFamilyName}</p>
<p className="text-sm">{c.body}</p>
</div>
</div>
))}
<div className="flex gap-2 pt-1">
<input
id="comment-input"
name="comment-input"
autoComplete="off"
value={commentText}
onChange={e => setCommentText(e.target.value)}
onKeyDown={e => e.key === "Enter" && addComment()}
placeholder="Add a comment…"
className="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none"
/>
<button
onClick={addComment}
disabled={posting || !commentText.trim()}
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm disabled:opacity-40"
></button>
</div>
</div>
)}
</div>
);
}
// ── Create Post Modal (C5 + C9 consent) ──────────────────────────────────────
function CreatePostModal({
circleId, circleName, memberCount,
onClose, onPosted,
}: {
circleId: string;
circleName: string;
memberCount: number;
onClose: () => void;
onPosted: () => void;
}) {
const [body, setBody] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const [step, setStep] = useState<"compose" | "confirm">("compose");
const [postError, setPostError] = useState<string | null>(null);
const pickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setImageFile(f);
setImagePreview(URL.createObjectURL(f));
setPostError(null);
};
const submit = async () => {
setPosting(true);
setPostError(null);
try {
let tmpKey: string | null = null;
if (imageFile) {
// Get presigned URL — pass both type and name so server can normalise empty types
const presignRes = await fetch(`/api/circles/${circleId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "presign", contentType: imageFile.type, filename: imageFile.name }),
});
const presignData = await presignRes.json();
if (!presignRes.ok || !presignData.uploadUrl) {
setPostError(presignData.error ?? "Could not prepare image upload");
setPosting(false);
return;
}
// Upload directly to R2 using the normalised content type from server
const uploadRes = await fetch(presignData.uploadUrl, {
method: "PUT",
body: imageFile,
headers: { "Content-Type": presignData.contentType ?? imageFile.type },
});
if (!uploadRes.ok) {
setPostError("Image upload failed — please try again");
setPosting(false);
return;
}
tmpKey = presignData.tmpKey;
}
const postRes = await fetch(`/api/circles/${circleId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postBody: body, tmpKey }),
});
const postData = await postRes.json();
if (!postRes.ok) {
setPostError(postData.error ?? "Failed to create post");
setPosting(false);
return;
}
onPosted();
onClose();
} catch (err) {
setPostError(err instanceof Error ? err.message : "Something went wrong");
}
setPosting(false);
};
return (
<>
<div className="fixed inset-0 bg-black/50 z-50" onClick={onClose} />
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">{step === "confirm" ? "Confirm post" : "New post"}</h3>
<button onClick={onClose} className="text-gray-400"></button>
</div>
{step === "compose" ? (
<>
<textarea
id="post-body"
name="post-body"
autoFocus
value={body}
onChange={e => setBody(e.target.value)}
placeholder="What's on your mind?"
rows={4}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none mb-3"
/>
{imagePreview && (
<div className="relative mb-3">
<img src={imagePreview} alt="Preview" className="w-full max-h-48 object-cover rounded-xl" />
<button
onClick={() => { setImageFile(null); setImagePreview(null); }}
className="absolute top-2 right-2 w-6 h-6 bg-black/50 text-white rounded-full text-xs"
></button>
</div>
)}
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer mb-4">
<span className="text-xl">📷</span> Add photo
<input id="post-image" name="post-image" type="file" accept="image/*" className="hidden" onChange={pickImage} />
</label>
<button
onClick={() => setStep("confirm")}
disabled={!body.trim() && !imageFile}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
>Continue </button>
</>
) : (
/* C9 — Explicit consent surface */
<div className="flex-1 flex flex-col gap-4">
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-2xl border border-amber-200 dark:border-amber-700">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-1">📣 Heads up</p>
<p className="text-sm text-amber-700 dark:text-amber-300">
This will be visible to all <strong>{memberCount} {memberCount === 1 ? "family" : "families"}</strong> in{" "}
<strong>{circleName}</strong>.{" "}
{imageFile && "The photo you selected will also be shared. "}
Once posted, other members can see it.
</p>
</div>
{body && <p className="text-sm bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2 italic text-gray-600 dark:text-gray-300">"{body}"</p>}
{postError && (
<p className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-xl px-3 py-2 mb-2">
{postError}
</p>
)}
<div className="flex gap-3 mt-auto">
<button onClick={() => setStep("compose")} className="flex-1 py-3 border border-gray-200 dark:border-gray-600 rounded-xl text-sm"> Edit</button>
<button
onClick={submit}
disabled={posting}
className="flex-1 py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
>{posting ? "Posting…" : "Post to Circle ✓"}</button>
</div>
</div>
)}
</div>
</>
);
}
// ── Circle feed page ──────────────────────────────────────────────────────────
export default function CircleFeedPage({ params }: { params: Promise<{ id: string }> }) {
const { id: circleId } = use(params);
const router = useRouter();
const { familyId } = useFamily();
const [circle, setCircle] = useState<Circle | null>(null);
const [posts, setPosts] = useState<CirclePost[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [showMembers, setShowMembers] = useState(false);
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
const [myRole, setMyRole] = useState<"admin" | "member">("member");
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteSending, setInviteSending] = useState(false);
const [inviteResult, setInviteResult] = useState<{ type: "success" | "error"; msg: string } | null>(null);
const fetchFeed = useCallback(async () => {
try {
const [circleRes, postsRes] = await Promise.all([
fetch(`/api/circles/${circleId}`),
fetch(`/api/circles/${circleId}/posts`),
]);
const circleData = await circleRes.json();
const postsData = await postsRes.json();
if (circleData.circle) {
setCircle(circleData.circle);
setMembers(circleData.members ?? []);
setMyRole(circleData.circle.role ?? "member");
}
setPosts(postsData.posts ?? []);
} catch { /* silent */ }
setLoading(false);
}, [circleId]);
useEffect(() => { fetchFeed(); }, [fetchFeed]);
const handleReact = async (postId: string, emoji: string) => {
await fetch(`/api/circles/${circleId}/posts/${postId}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emoji }),
});
// Refresh just this post's reactions
fetchFeed();
};
const handleInviteSend = async () => {
if (!inviteEmail.trim()) return;
setInviteSending(true);
setInviteResult(null);
try {
const res = await fetch(`/api/circles/${circleId}/invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: inviteEmail.trim() }),
});
const data = await res.json();
if (data.success) {
const msg = data.type === "in_app"
? `Invite sent! ${inviteEmail} will see it on their Circles page.`
: `Invite email sent to ${inviteEmail}!`;
setInviteResult({ type: "success", msg });
setInviteEmail("");
} else {
setInviteResult({ type: "error", msg: data.error ?? "Failed to send invite" });
}
} catch {
setInviteResult({ type: "error", msg: "Something went wrong" });
}
setInviteSending(false);
};
const handleLeave = async () => {
if (!confirm("Leave this circle?")) return;
const res = await fetch(`/api/circles/${circleId}/members`, { method: "DELETE" });
const data = await res.json();
if (data.success) router.push("/circle");
else alert(data.error);
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="flex gap-3 text-3xl">
{["👨‍👩‍👧", "💬", "❤️"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
</div>
);
}
if (!circle) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400">
<div className="text-center">
<p className="text-4xl mb-2">🔒</p>
<p>Circle not found or you are not a member.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Header */}
<div className="p-4 flex items-center gap-3">
<button onClick={() => router.push("/circle")} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold truncate">{circle.name}</h1>
<p className="text-xs text-gray-400">{circle.memberCount} {circle.memberCount === 1 ? "family" : "families"}</p>
</div>
{/* Members panel toggle */}
<button
onClick={() => setShowMembers(v => !v)}
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-sm"
>👥</button>
{/* Admin: invite */}
{myRole === "admin" && (
<button
onClick={() => { setShowInviteModal(true); setInviteResult(null); setInviteEmail(""); }}
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>+ Invite</button>
)}
</div>
{/* Members panel */}
{showMembers && (
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-sm">Members</p>
<button onClick={handleLeave} className="text-xs text-red-400">Leave circle</button>
</div>
<div className="space-y-2">
{members.map(m => (
<div key={m.familyId} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xs">👨👩👧</div>
<span className="text-sm">{m.familyName}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${m.role === "admin" ? "bg-rose-100 text-rose-600" : "bg-gray-100 text-gray-500"}`}>
{m.role}
</span>
{myRole === "admin" && m.familyId !== familyId && (
<button
onClick={async () => {
await fetch(`/api/circles/${circleId}/members/${m.familyId}`, { method: "DELETE" });
fetchFeed();
}}
className="text-xs text-red-400"
>Remove</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Posts feed */}
<div className="px-4 space-y-4">
{posts.length === 0 ? (
<div className="text-center py-16 px-8">
<p className="text-4xl mb-3">💬</p>
<p className="font-semibold text-gray-700 dark:text-gray-200">No posts yet</p>
<p className="text-sm text-gray-400 mt-1">Be the first to share something with {circle.name}!</p>
</div>
) : (
posts.map(p => (
<PostCard
key={p.id}
post={p}
myFamilyId={familyId ?? ""}
circleId={circleId}
isAdmin={myRole === "admin"}
onDeleted={id => setPosts(prev => prev.filter(x => x.id !== id))}
onReact={handleReact}
/>
))
)}
</div>
{/* FAB — create post */}
<button
onClick={() => setShowCreate(true)}
className="fixed bottom-20 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-40"
>+</button>
{showCreate && circle && familyId && (
<CreatePostModal
circleId={circleId}
circleName={circle.name}
memberCount={circle.memberCount}
onClose={() => setShowCreate(false)}
onPosted={fetchFeed}
/>
)}
{/* Invite by email modal */}
{showInviteModal && (
<>
<div className="fixed inset-0 bg-black/50 z-50" onClick={() => setShowInviteModal(false)} />
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-5 pb-10 shadow-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Invite to {circle?.name}</h3>
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 text-xl"></button>
</div>
<p className="text-sm text-gray-500 mb-4">
Enter the email address of the person you want to invite.
If they already have a Tia account they'll get a notification;
otherwise we'll email them a link to sign up and join.
</p>
<input
id="invite-email"
name="invite-email"
type="email"
autoFocus
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleInviteSend()}
placeholder="their@email.com"
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none mb-3 border border-gray-200 dark:border-gray-600"
/>
{inviteResult && (
<p className={`text-sm mb-3 px-3 py-2 rounded-xl ${
inviteResult.type === "success"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-300"
}`}>
{inviteResult.type === "success" ? "✓ " : "✗ "}{inviteResult.msg}
</p>
)}
<button
onClick={handleInviteSend}
disabled={inviteSending || !inviteEmail.trim()}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
>
{inviteSending ? "Sending…" : "Send Invite"}
</button>
</div>
</>
)}
</div>
);
}