Circle feature: C0–C9 multi-tenant social groups (Sprint 9 + 10)

Adds full circle functionality — private social groups for trusted families
to share milestones, memories, and posts with reactions and comments.

- 7-table DB migration: circles, members, invites, posts, comments, reactions, reports
- 11 API routes: create/list circles, posts feed, comments, emoji reactions, invite tokens, join flow, member management, reporting
- 3 new pages: /circle (list), /circle/[id] (feed + PostCard + CreatePostModal), /circle/join/[token]
- Copy-on-share for memory photos (independent R2 objects, never references originals)
- Admin controls: invite generation, member promote/demote/remove, last-admin guard
- C9 privacy consent screen before first post
- Menu entry added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 01:04:50 +05:30
parent c732d2d7c2
commit 5fdb69679d
17 changed files with 1659 additions and 0 deletions

87
drizzle/0003_circles.sql Normal file
View file

@ -0,0 +1,87 @@
-- C0: Circle multi-tenant social tables
-- Security model: RLS enabled (deny-by-default at DB layer).
-- App-level enforcement via requireFamily() + WHERE family_id checks
-- mirrors the existing pattern used for all other tables in this codebase.
CREATE TABLE circles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid NOT NULL REFERENCES families(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE circle_members (
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
family_id uuid NOT NULL REFERENCES families(id),
role text NOT NULL DEFAULT 'member', -- admin | member
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (circle_id, family_id)
);
CREATE TABLE circle_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
token text NOT NULL UNIQUE, -- crypto-random, unguessable
created_by uuid NOT NULL REFERENCES families(id),
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE circle_posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE,
author_family_id uuid NOT NULL REFERENCES families(id),
body text,
image_key text, -- R2 key under circle-posts/{id}/ prefix
source_kind text, -- NULL | 'milestone' | 'memory'
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE circle_post_comments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
author_family_id uuid NOT NULL REFERENCES families(id),
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE circle_post_reactions (
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
family_id uuid NOT NULL REFERENCES families(id),
emoji text NOT NULL,
PRIMARY KEY (post_id, family_id, emoji)
);
CREATE TABLE post_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE,
reported_by uuid NOT NULL REFERENCES families(id),
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Indexes for common query patterns
CREATE INDEX circle_members_family_idx ON circle_members(family_id);
CREATE INDEX circle_members_circle_idx ON circle_members(circle_id);
CREATE INDEX circle_posts_circle_idx ON circle_posts(circle_id);
CREATE INDEX circle_posts_author_idx ON circle_posts(author_family_id);
CREATE INDEX circle_comments_post_idx ON circle_post_comments(post_id);
CREATE INDEX circle_reactions_post_idx ON circle_post_reactions(post_id);
CREATE INDEX circle_invites_token_idx ON circle_invites(token);
-- Enable RLS (deny-by-default at DB layer).
-- The app connects as tia_app which handles row visibility via
-- explicit WHERE clauses in every query (requireFamily() pattern).
ALTER TABLE circles ENABLE ROW LEVEL SECURITY;
ALTER TABLE circle_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE circle_invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE circle_posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE circle_post_comments ENABLE ROW LEVEL SECURITY;
ALTER TABLE circle_post_reactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE post_reports ENABLE ROW LEVEL SECURITY;
-- Grant app role full access (app enforces row visibility itself)
GRANT ALL ON circles, circle_members, circle_invites, circle_posts,
circle_post_comments, circle_post_reactions, post_reports
TO tia_app;

View file

@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
// POST — validate invite token and add family as a member
export async function POST(
_req: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { token } = await params;
// Validate token server-side — never trust client-supplied circleId
const [invite] = await sql.unsafe(
`SELECT id, circle_id as "circleId", expires_at as "expiresAt", consumed_at as "consumedAt"
FROM circle_invites WHERE token = $1`,
[token]
);
if (!invite) return NextResponse.json({ error: "Invalid invite link" }, { status: 404 });
if (invite.consumedAt) return NextResponse.json({ error: "This invite has already been used" }, { status: 409 });
if (new Date(invite.expiresAt) < new Date()) return NextResponse.json({ error: "This invite has expired" }, { status: 410 });
// Idempotent — already a member
const existing = await sql.unsafe(
`SELECT 1 FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[invite.circleId, familyId]
);
if (existing.length > 0) {
return NextResponse.json({ error: "You are already a member of this circle" }, { status: 409 });
}
// Add member + consume invite atomically
await sql.unsafe(
`INSERT INTO circle_members (circle_id, family_id, role) VALUES ($1, $2, 'member')`,
[invite.circleId, familyId]
);
await sql.unsafe(
`UPDATE circle_invites SET consumed_at = now() WHERE id = $1`,
[invite.id]
);
const [circle] = await sql.unsafe(
`SELECT id, name FROM circles WHERE id = $1`,
[invite.circleId]
);
return NextResponse.json({ success: true, circleId: invite.circleId, circleName: circle?.name });
}
// GET — preview the circle behind a token (name, member count) without joining
export async function GET(
_req: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const [invite] = await sql.unsafe(
`SELECT ci.id, ci.circle_id as "circleId", ci.expires_at as "expiresAt",
ci.consumed_at as "consumedAt", c.name as "circleName",
(SELECT COUNT(*) FROM circle_members WHERE circle_id = c.id)::int as "memberCount"
FROM circle_invites ci
JOIN circles c ON c.id = ci.circle_id
WHERE ci.token = $1`,
[token]
);
if (!invite) return NextResponse.json({ error: "Invalid invite link" }, { status: 404 });
if (invite.consumedAt) return NextResponse.json({ error: "This invite has already been used" }, { status: 409 });
if (new Date(invite.expiresAt) < new Date()) return NextResponse.json({ error: "This invite has expired" }, { status: 410 });
return NextResponse.json({
circleName: invite.circleName,
memberCount: invite.memberCount,
expiresAt: invite.expiresAt,
});
}

View file

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { randomBytes } from "crypto";
// POST — create a single-use invite link (admin only)
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId } = await params;
// Only admins can create invites
const rows = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
if (!rows[0] || rows[0].role !== "admin") {
return NextResponse.json({ error: "Only circle admins can create invites" }, { status: 403 });
}
// Cryptographically random 32-byte token (64 hex chars) — unguessable
const token = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const [invite] = await sql.unsafe(
`INSERT INTO circle_invites (circle_id, token, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, token, expires_at as "expiresAt"`,
[circleId, token, familyId, expiresAt]
);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com";
return NextResponse.json({
success: true,
invite: { ...invite, joinUrl: `${baseUrl}/circle/join/${token}` },
});
}

View file

@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
// DELETE — admin removes a member OR promotes/demotes (via PATCH)
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; memberId: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId, memberId: targetFamilyId } = await params;
// Only admins can remove other members
const [membership] = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
if (membership?.role !== "admin") {
return NextResponse.json({ error: "Only circle admins can remove members" }, { status: 403 });
}
// Admins cannot remove themselves via this route (use /members DELETE to leave)
if (targetFamilyId === familyId) {
return NextResponse.json({ error: "Use the leave action to remove yourself" }, { status: 400 });
}
await sql.unsafe(
`DELETE FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, targetFamilyId]
);
return NextResponse.json({ success: true });
}
// PATCH — promote member to admin (admin only)
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string; memberId: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId, memberId: targetFamilyId } = await params;
const [membership] = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
if (membership?.role !== "admin") {
return NextResponse.json({ error: "Only admins can change roles" }, { status: 403 });
}
const { role } = await req.json();
if (!["admin", "member"].includes(role)) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 });
}
await sql.unsafe(
`UPDATE circle_members SET role = $1 WHERE circle_id = $2 AND family_id = $3`,
[role, circleId, targetFamilyId]
);
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
// GET — list members + reported posts (admins only see reports)
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId } = await params;
const [membership] = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
if (!membership) return NextResponse.json({ error: "Not a member" }, { status: 403 });
const members = await sql.unsafe(
`SELECT cm.family_id as "familyId", f.name as "familyName", cm.role, cm.joined_at as "joinedAt"
FROM circle_members cm
JOIN families f ON f.id = cm.family_id
WHERE cm.circle_id = $1
ORDER BY cm.joined_at ASC`,
[circleId]
);
// Admins also see pending reports
let reports: unknown[] = [];
if (membership.role === "admin") {
reports = await sql.unsafe(
`SELECT pr.id, pr.post_id as "postId", pr.reason,
f.name as "reportedByName", pr.created_at as "createdAt",
LEFT(p.body, 100) as "postPreview"
FROM post_reports pr
JOIN families f ON f.id = pr.reported_by
JOIN circle_posts p ON p.id = pr.post_id
WHERE p.circle_id = $1
ORDER BY pr.created_at DESC`,
[circleId]
);
}
return NextResponse.json({ members, reports, myRole: membership.role });
}
// DELETE — leave the circle (self)
// Edge case: last admin cannot leave without first promoting another member
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId } = await params;
const [membership] = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
if (!membership) return NextResponse.json({ error: "Not a member" }, { status: 404 });
// Block last-admin from leaving
if (membership.role === "admin") {
const [{ adminCount }] = await sql.unsafe(
`SELECT COUNT(*)::int as "adminCount" FROM circle_members WHERE circle_id = $1 AND role = 'admin'`,
[circleId]
);
if (adminCount <= 1) {
return NextResponse.json({
error: "You are the only admin. Promote another member before leaving.",
}, { status: 400 });
}
}
await sql.unsafe(
`DELETE FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
async function assertMemberOfPost(postId: string, familyId: string) {
const rows = await sql.unsafe(
`SELECT cm.role FROM circle_posts p
JOIN circle_members cm ON cm.circle_id = p.circle_id AND cm.family_id = $2
WHERE p.id = $1`,
[postId, familyId]
);
return rows[0]?.role ?? null;
}
// GET — list comments for a post
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { postId } = await params;
if (!(await assertMemberOfPost(postId, familyId))) {
return NextResponse.json({ error: "Not a member" }, { status: 403 });
}
const comments = await sql.unsafe(
`SELECT c.id, c.author_family_id as "authorFamilyId", f.name as "authorFamilyName",
c.body, c.created_at as "createdAt"
FROM circle_post_comments c
JOIN families f ON f.id = c.author_family_id
WHERE c.post_id = $1
ORDER BY c.created_at ASC`,
[postId]
);
return NextResponse.json({ comments });
}
// POST — add a comment (members only; AI assistant not permitted here)
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { postId } = await params;
if (!(await assertMemberOfPost(postId, familyId))) {
return NextResponse.json({ error: "Not a member" }, { status: 403 });
}
const { body } = await req.json();
if (!body?.trim()) return NextResponse.json({ error: "Comment cannot be empty" }, { status: 400 });
const [comment] = await sql.unsafe(
`INSERT INTO circle_post_comments (post_id, author_family_id, body)
VALUES ($1, $2, $3)
RETURNING id, author_family_id as "authorFamilyId", body, created_at as "createdAt"`,
[postId, familyId, body.trim()]
);
return NextResponse.json({ success: true, comment });
}

View file

@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
const ALLOWED_EMOJIS = ["❤️", "😂", "👍", "🙏", "😮", "😢"];
async function assertMemberOfPost(postId: string, familyId: string) {
const rows = await sql.unsafe(
`SELECT 1 FROM circle_posts p
JOIN circle_members cm ON cm.circle_id = p.circle_id AND cm.family_id = $2
WHERE p.id = $1`,
[postId, familyId]
);
return rows.length > 0;
}
// POST — toggle reaction (composite PK prevents duplicates)
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { postId } = await params;
if (!(await assertMemberOfPost(postId, familyId))) {
return NextResponse.json({ error: "Not a member" }, { status: 403 });
}
const { emoji } = await req.json();
if (!ALLOWED_EMOJIS.includes(emoji)) {
return NextResponse.json({ error: "Emoji not allowed" }, { status: 400 });
}
// Toggle: insert if not exists, delete if exists
const existing = await sql.unsafe(
`SELECT 1 FROM circle_post_reactions WHERE post_id = $1 AND family_id = $2 AND emoji = $3`,
[postId, familyId, emoji]
);
if (existing.length > 0) {
await sql.unsafe(
`DELETE FROM circle_post_reactions WHERE post_id = $1 AND family_id = $2 AND emoji = $3`,
[postId, familyId, emoji]
);
return NextResponse.json({ success: true, action: "removed" });
}
await sql.unsafe(
`INSERT INTO circle_post_reactions (post_id, family_id, emoji) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
[postId, familyId, emoji]
);
return NextResponse.json({ success: true, action: "added" });
}

View file

@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
// POST — report a post (members only; admins see reports in circle detail)
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { postId } = await params;
const membership = await sql.unsafe(
`SELECT 1 FROM circle_posts p
JOIN circle_members cm ON cm.circle_id = p.circle_id AND cm.family_id = $2
WHERE p.id = $1`,
[postId, familyId]
);
if (!membership.length) return NextResponse.json({ error: "Not a member" }, { status: 403 });
const { reason } = await req.json();
await sql.unsafe(
`INSERT INTO post_reports (post_id, reported_by, reason) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
[postId, familyId, reason ?? null]
);
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
function makeR2Client() {
return new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
// DELETE — author or circle admin can delete a post; deletes R2 copy too
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; postId: string }> }
) {
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 p.id, p.author_family_id as "authorFamilyId", p.image_key as "imageKey"
FROM circle_posts p WHERE p.id = $1 AND p.circle_id = $2`,
[postId, circleId]
);
if (!post) return NextResponse.json({ error: "Post not found" }, { status: 404 });
// Permission: own post OR admin of this circle
const isAuthor = post.authorFamilyId === familyId;
if (!isAuthor) {
const [membership] = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
if (membership?.role !== "admin") {
return NextResponse.json({ error: "Not authorised to delete this post" }, { status: 403 });
}
}
// Delete R2 image copy (circle-posts/{postId}/ prefix) — no orphans
if (post.imageKey) {
await makeR2Client().send(
new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: post.imageKey })
).catch(() => {}); // don't fail the whole delete if R2 is flaky
}
// DB cascade removes comments, reactions, reports
await sql.unsafe(`DELETE FROM circle_posts WHERE id = $1`, [postId]);
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,196 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { S3Client, PutObjectCommand, CopyObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? "";
function makeR2Client() {
return new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
async function assertMember(circleId: string, familyId: string) {
const rows = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
return rows[0]?.role ?? null;
}
// GET — circle feed (newest first)
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId } = await params;
if (!(await assertMember(circleId, familyId))) {
return NextResponse.json({ error: "Not a member" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50);
const before = searchParams.get("before"); // cursor: ISO timestamp
const posts = await sql.unsafe(
`SELECT p.id, p.circle_id as "circleId", p.author_family_id as "authorFamilyId",
f.name as "authorFamilyName", p.body, p.image_key as "imageKey",
p.source_kind as "sourceKind", p.created_at as "createdAt",
(SELECT COUNT(*) FROM circle_post_comments WHERE post_id = p.id)::int as "commentCount"
FROM circle_posts p
JOIN families f ON f.id = p.author_family_id
WHERE p.circle_id = $1
AND ($2::timestamptz IS NULL OR p.created_at < $2::timestamptz)
ORDER BY p.created_at DESC
LIMIT $3`,
[circleId, before ?? null, limit]
);
// Attach reactions per post
const postIds = posts.map((p: Record<string, unknown>) => p.id as string);
let reactions: Array<{ postId: string; emoji: string; count: number; familyId: string }> = [];
if (postIds.length > 0) {
reactions = await sql.unsafe(
`SELECT post_id as "postId", emoji,
COUNT(*)::int as count,
bool_or(family_id = $2) as "includedMe"
FROM circle_post_reactions
WHERE post_id = ANY($1::uuid[])
GROUP BY post_id, emoji`,
[postIds, familyId]
);
}
const reactionsByPost = reactions.reduce<Record<string, typeof reactions>>((acc, r) => {
if (!acc[r.postId]) acc[r.postId] = [];
acc[r.postId].push(r);
return acc;
}, {});
type PostRow = Record<string, unknown> & { id: string; imageKey: string | null };
const result = (posts as unknown as PostRow[]).map(p => ({
...p,
imageUrl: p.imageKey ? `${R2_PUBLIC_URL}/${p.imageKey}` : null,
reactions: (reactionsByPost[p.id] ?? []).map(r => ({
emoji: r.emoji, count: r.count, includedMe: r.familyId === familyId,
})),
}));
return NextResponse.json({ posts: result });
}
// POST — create a post or request a presigned upload URL
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id: circleId } = await params;
if (!(await assertMember(circleId, familyId))) {
return NextResponse.json({ error: "Not a member" }, { status: 403 });
}
const body = await req.json();
// ── Sub-action: get presigned upload URL for a circle post image ──────────
if (body.action === "presign") {
const { contentType, filename } = body;
const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
if (!contentType || !ALLOWED.includes(contentType)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
const ext = filename?.split(".").pop()?.toLowerCase() ?? "jpg";
// Temp key — will be moved to circle-posts/{post_id}/ after post is created
const tmpKey = `circle-posts/tmp/${familyId}/${Date.now()}.${ext}`;
const uploadUrl = await getSignedUrl(
makeR2Client(),
new PutObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: tmpKey, ContentType: contentType }),
{ expiresIn: 3600 }
);
return NextResponse.json({ uploadUrl, tmpKey, publicUrl: `${R2_PUBLIC_URL}/${tmpKey}` });
}
// ── Sub-action: share from a private memory (copy-on-share) ──────────────
if (body.action === "shareMemory") {
const { memoryId, postBody } = body;
// Verify the family owns this memory
const [memory] = await sql.unsafe(
`SELECT r2_key, r2_thumbnail_key FROM memories WHERE id = $1 AND family_id = $2`,
[memoryId, familyId]
);
if (!memory) return NextResponse.json({ error: "Memory not found or not yours" }, { status: 404 });
// Create post row first to get the post ID
const [post] = await sql.unsafe(
`INSERT INTO circle_posts (circle_id, author_family_id, body, source_kind)
VALUES ($1, $2, $3, 'memory')
RETURNING id`,
[circleId, familyId, postBody ?? null]
);
// Copy image to circle-posts/{post_id}/ — independent object, never references original
let copiedKey: string | null = null;
if (memory.r2_key) {
const ext = memory.r2_key.split(".").pop() ?? "jpg";
copiedKey = `circle-posts/${post.id}/image.${ext}`;
await makeR2Client().send(new CopyObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
CopySource: `${process.env.R2_BUCKET_NAME}/${memory.r2_key}`,
Key: copiedKey,
}));
await sql.unsafe(
`UPDATE circle_posts SET image_key = $1 WHERE id = $2`,
[copiedKey, post.id]
);
}
return NextResponse.json({ success: true, postId: post.id });
}
// ── Default: create a fresh post ─────────────────────────────────────────
const { postBody, tmpKey } = body;
if (!postBody?.trim() && !tmpKey) {
return NextResponse.json({ error: "Post must have text or an image" }, { status: 400 });
}
const [post] = await sql.unsafe(
`INSERT INTO circle_posts (circle_id, author_family_id, body)
VALUES ($1, $2, $3)
RETURNING id`,
[circleId, familyId, postBody?.trim() ?? null]
);
// Move temp image to permanent key under circle-posts/{post_id}/
let finalKey: string | null = null;
if (tmpKey) {
const ext = tmpKey.split(".").pop() ?? "jpg";
finalKey = `circle-posts/${post.id}/image.${ext}`;
await makeR2Client().send(new CopyObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
CopySource: `${process.env.R2_BUCKET_NAME}/${tmpKey}`,
Key: finalKey,
}));
await sql.unsafe(
`UPDATE circle_posts SET image_key = $1 WHERE id = $2`,
[finalKey, post.id]
);
}
return NextResponse.json({ success: true, postId: post.id });
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
// Helper: verify family is a member; returns role or null
async function getMemberRole(circleId: string, familyId: string): Promise<string | null> {
const rows = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId]
);
return rows[0]?.role ?? null;
}
// GET — circle detail + members list
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
const role = await getMemberRole(id, familyId);
if (!role) return NextResponse.json({ error: "Not a member of this circle" }, { status: 403 });
const [circle] = await sql.unsafe(
`SELECT id, name, created_by as "createdBy", created_at as "createdAt" FROM circles WHERE id = $1`,
[id]
);
if (!circle) return NextResponse.json({ error: "Circle not found" }, { status: 404 });
const members = await sql.unsafe(
`SELECT cm.family_id as "familyId", f.name as "familyName", cm.role, cm.joined_at as "joinedAt"
FROM circle_members cm
JOIN families f ON f.id = cm.family_id
WHERE cm.circle_id = $1
ORDER BY cm.joined_at ASC`,
[id]
);
return NextResponse.json({ circle: { ...circle, role, memberCount: members.length }, members });
}

View file

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
// GET — list all circles this family belongs to
export async function GET() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const circles = await sql.unsafe(
`SELECT c.id, c.name, c.created_by as "createdBy", c.created_at as "createdAt",
cm.role,
(SELECT COUNT(*) FROM circle_members WHERE circle_id = c.id)::int as "memberCount"
FROM circles c
JOIN circle_members cm ON cm.circle_id = c.id AND cm.family_id = $1
ORDER BY c.created_at DESC`,
[familyId]
);
return NextResponse.json({ circles });
}
// POST — create a new circle (creator becomes admin)
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { name } = await request.json();
if (!name?.trim()) {
return NextResponse.json({ error: "Circle name is required" }, { status: 400 });
}
const [circle] = await sql.unsafe(
`INSERT INTO circles (name, created_by) VALUES ($1, $2)
RETURNING id, name, created_by as "createdBy", created_at as "createdAt"`,
[name.trim(), familyId]
);
// Creator is automatically an admin member
await sql.unsafe(
`INSERT INTO circle_members (circle_id, family_id, role) VALUES ($1, $2, 'admin')`,
[circle.id, familyId]
);
return NextResponse.json({ success: true, circle: { ...circle, role: "admin", memberCount: 1 } });
}

View file

@ -0,0 +1,496 @@
"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
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 pickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
setImageFile(f);
setImagePreview(URL.createObjectURL(f));
};
const submit = async () => {
setPosting(true);
try {
let tmpKey: string | null = null;
if (imageFile) {
// Get presigned URL
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 { uploadUrl, tmpKey: key } = await presignRes.json();
// Upload to R2
await fetch(uploadUrl, { method: "PUT", body: imageFile, headers: { "Content-Type": imageFile.type } });
tmpKey = key;
}
await fetch(`/api/circles/${circleId}/posts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postBody: body, tmpKey }),
});
onPosted();
onClose();
} catch { /* silent */ }
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
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 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>}
<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 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 handleInvite = async () => {
const res = await fetch(`/api/circles/${circleId}/invite`, { method: "POST" });
const data = await res.json();
if (data.invite?.joinUrl) {
await navigator.clipboard.writeText(data.invite.joinUrl).catch(() => {});
alert(`Invite link copied!\n\n${data.invite.joinUrl}\n\nExpires in 7 days.`);
}
};
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={handleInvite}
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}
/>
)}
</div>
);
}

View file

@ -0,0 +1,124 @@
"use client";
import { useState, useEffect, use } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "../../../FamilyProvider";
type State = "loading" | "preview" | "joining" | "success" | "error";
export default function JoinCirclePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
const router = useRouter();
const { familyId, loading: authLoading } = useFamily();
const [state, setState] = useState<State>("loading");
const [circleName, setCircleName] = useState("");
const [memberCount, setMemberCount] = useState(0);
const [errorMsg, setErrorMsg] = useState("");
// Preview the circle info (no auth required)
useEffect(() => {
fetch(`/api/circle/join/${token}`)
.then(r => r.json())
.then(data => {
if (data.error) { setErrorMsg(data.error); setState("error"); return; }
setCircleName(data.circleName);
setMemberCount(data.memberCount);
setState("preview");
})
.catch(() => { setErrorMsg("Could not load this invite."); setState("error"); });
}, [token]);
const joinCircle = async () => {
if (!familyId) {
// Not logged in — redirect to login with return URL
router.push(`/login?next=/circle/join/${token}`);
return;
}
setState("joining");
try {
const res = await fetch(`/api/circle/join/${token}`, { method: "POST" });
const data = await res.json();
if (data.success) {
setState("success");
setTimeout(() => router.push(`/circle/${data.circleId}`), 1500);
} else {
setErrorMsg(data.error ?? "Could not join circle.");
setState("error");
}
} catch {
setErrorMsg("Something went wrong. Please try again.");
setState("error");
}
};
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 p-6">
<div className="w-full max-w-sm bg-white dark:bg-gray-800 rounded-3xl shadow-xl p-6 text-center">
{(state === "loading" || authLoading) && (
<>
<div className="flex justify-center gap-2 text-3xl mb-4">
{["👨‍👩‍👧", "❤️", "👶"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
<p className="text-gray-500">Loading invite</p>
</>
)}
{state === "preview" && !authLoading && (
<>
<div className="w-16 h-16 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-3xl mx-auto mb-4">
👨👩👧👦
</div>
<h1 className="text-xl font-bold mb-1">You're invited!</h1>
<p className="text-gray-500 text-sm mb-4">
Join <strong className="text-gray-800 dark:text-white">{circleName}</strong>
<br />
<span className="text-xs">{memberCount} {memberCount === 1 ? "family" : "families"} already inside</span>
</p>
{!familyId && (
<p className="text-xs text-amber-600 bg-amber-50 rounded-xl px-3 py-2 mb-4">
You'll need to log in to Tia first.
</p>
)}
<button
onClick={joinCircle}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-semibold"
>
{familyId ? `Join ${circleName}` : "Log in to Join"}
</button>
<button onClick={() => router.push("/")} className="mt-3 text-xs text-gray-400">
Not now
</button>
</>
)}
{state === "joining" && (
<>
<div className="w-12 h-12 border-4 border-rose-400 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500">Joining circle</p>
</>
)}
{state === "success" && (
<>
<div className="text-5xl mb-4">🎉</div>
<h1 className="text-xl font-bold mb-1">You're in!</h1>
<p className="text-gray-500 text-sm">Welcome to <strong>{circleName}</strong>. Taking you there</p>
</>
)}
{state === "error" && (
<>
<div className="text-5xl mb-4">😕</div>
<h1 className="text-lg font-bold mb-2 text-gray-800 dark:text-white">Invite issue</h1>
<p className="text-gray-500 text-sm mb-5">{errorMsg}</p>
<button onClick={() => router.push("/")} className="w-full py-3 bg-gray-100 dark:bg-gray-700 rounded-xl text-sm">Go to Home</button>
</>
)}
</div>
</div>
);
}

125
src/app/circle/page.tsx Normal file
View file

@ -0,0 +1,125 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useFamily } from "../FamilyProvider";
import type { Circle } from "@/types";
export default function CirclePage() {
const router = useRouter();
const { familyId } = useFamily();
const [circles, setCircles] = useState<Circle[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [showCreate, setShowCreate] = useState(false);
useEffect(() => {
if (familyId) fetchCircles();
}, [familyId]);
const fetchCircles = async () => {
try {
const res = await fetch("/api/circles");
const data = await res.json();
setCircles(data.circles ?? []);
} catch { /* silent */ }
setLoading(false);
};
const createCircle = async () => {
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/circles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
const data = await res.json();
if (data.success) {
setNewName("");
setShowCreate(false);
router.push(`/circle/${data.circle.id}`);
}
} catch { /* silent */ }
setCreating(false);
};
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.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl"></button>
<div className="flex-1">
<h1 className="text-xl font-bold">My Circles</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">Private groups with trusted families</p>
</div>
<button
onClick={() => setShowCreate(v => !v)}
className="px-4 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>+ New</button>
</div>
{/* Create circle form */}
{showCreate && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">Create a new Circle</p>
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === "Enter" && createCircle()}
placeholder="e.g. NCB Mamas 2024"
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none border border-gray-200 dark:border-gray-600 focus:border-rose-400"
/>
<div className="flex gap-2">
<button
onClick={createCircle}
disabled={creating || !newName.trim()}
className="flex-1 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium disabled:opacity-50"
>{creating ? "Creating…" : "Create Circle"}</button>
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-gray-400 text-sm">Cancel</button>
</div>
</div>
)}
{/* List */}
<div className="px-4 space-y-3">
{loading ? (
<div className="flex justify-center py-16">
<div className="flex gap-2 text-3xl">
{["👨‍👩‍👧", "💬", "❤️"].map((e, i) => (
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
))}
</div>
</div>
) : circles.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 circles yet</p>
<p className="text-sm text-gray-400 mt-1">Create one and invite trusted families to share milestones privately.</p>
</div>
) : (
circles.map(c => (
<Link
key={c.id}
href={`/circle/${c.id}`}
className="flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm"
>
<div className="w-12 h-12 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-2xl flex-shrink-0">
👨👩👧
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold truncate">{c.name}</div>
<div className="text-xs text-gray-400">{c.memberCount} {c.memberCount === 1 ? "family" : "families"} · {c.role === "admin" ? "Admin" : "Member"}</div>
</div>
<span className="text-gray-400"></span>
</Link>
))
)}
</div>
</div>
);
}

View file

@ -17,6 +17,7 @@ export default function MenuPage() {
{ icon: "🤖", label: "AI Chat", href: "/ai" }, { icon: "🤖", label: "AI Chat", href: "/ai" },
{ icon: "🌟", label: "Milestones", href: "/milestones" }, { icon: "🌟", label: "Milestones", href: "/milestones" },
{ icon: "👗", label: "Wardrobe", href: "/wardrobe" }, { icon: "👗", label: "Wardrobe", href: "/wardrobe" },
{ icon: "👨‍👩‍👧", label: "Circle", href: "/circle" },
]; ];
const handleSignOut = async () => { const handleSignOut = async () => {

View file

@ -101,3 +101,48 @@ export interface ChatSession {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
// ── Circle types ──────────────────────────────────────────────────────────────
export interface Circle {
id: string;
name: string;
createdBy: string;
createdAt: string;
role: "admin" | "member";
memberCount: number;
}
export interface CircleMember {
familyId: string;
familyName: string;
role: "admin" | "member";
joinedAt: string;
}
export interface CirclePost {
id: string;
circleId: string;
authorFamilyId: string;
authorFamilyName: string;
body: string | null;
imageUrl: string | null;
sourceKind: "milestone" | "memory" | null;
createdAt: string;
reactions: CircleReaction[];
commentCount: number;
}
export interface CircleReaction {
emoji: string;
count: number;
includedMe: boolean;
}
export interface CircleComment {
id: string;
authorFamilyId: string;
authorFamilyName: string;
body: string;
createdAt: string;
}