Posts start collapsed showing author, timestamp, a thumbnail (if image) and truncated text preview with ▼ chevron. Tap the author row to expand the full post (body, full image, reactions, comments). Tap again to collapse. Lets users scan many posts quickly and expand only what interests them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
678 lines
28 KiB
TypeScript
678 lines
28 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 [collapsed, setCollapsed] = useState(true);
|
||
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 [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`);
|
||
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 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 (
|
||
<>
|
||
{/* 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 — tap to collapse/expand */}
|
||
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
||
<button
|
||
onClick={() => setCollapsed(v => !v)}
|
||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||
>
|
||
<div className="w-8 h-8 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-sm flex-shrink-0">👨👩👧</div>
|
||
<div className="flex-1 min-w-0">
|
||
<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>
|
||
{/* Collapsed preview */}
|
||
{collapsed && (
|
||
<div className="flex items-center gap-2 flex-shrink-0 max-w-[45%]">
|
||
{post.imageUrl && (
|
||
<img src={post.imageUrl} alt="" className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
|
||
)}
|
||
{post.body && (
|
||
<span className="text-xs text-gray-400 truncate">{post.body}</span>
|
||
)}
|
||
<span className="text-gray-300 text-xs flex-shrink-0">▼</span>
|
||
</div>
|
||
)}
|
||
{!collapsed && <span className="text-gray-300 text-xs flex-shrink-0 ml-2">▲</span>}
|
||
</button>
|
||
{/* ⋯ 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 text-lg">⋯</button>
|
||
{showMenu && (
|
||
<>
|
||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||
<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); }}
|
||
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>
|
||
|
||
{/* Collapsed divider */}
|
||
{collapsed && <div className="border-t border-gray-50 dark:border-gray-700 rounded-b-2xl" />}
|
||
|
||
{/* Expanded content */}
|
||
{!collapsed && <>
|
||
|
||
{/* 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 — 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 — contain (no crop) + tap for fullscreen */}
|
||
{post.imageUrl && (
|
||
<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 */}
|
||
<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>
|
||
)}
|
||
</>} {/* end !collapsed */}
|
||
</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) {
|
||
// Upload via server (avoids CORS issues with direct R2 PUT)
|
||
const fd = new FormData();
|
||
fd.append("file", imageFile);
|
||
const uploadRes = await fetch(`/api/circles/${circleId}/posts/upload`, {
|
||
method: "POST",
|
||
body: fd,
|
||
});
|
||
const uploadData = await uploadRes.json();
|
||
if (!uploadRes.ok) {
|
||
setPostError(uploadData.error ?? "Image upload failed");
|
||
setPosting(false);
|
||
return;
|
||
}
|
||
tmpKey = uploadData.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>
|
||
);
|
||
}
|