- 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>
597 lines
24 KiB
TypeScript
597 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|