diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts new file mode 100644 index 0000000..0fbcebd --- /dev/null +++ b/src/app/api/posts/[id]/comments/route.ts @@ -0,0 +1,34 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createComment, listComments } from "@/lib/comments"; +import { SESSION_COOKIE } from "@/lib/cookie"; + +// GET /api/posts/:id/comments -> feed GET /api/v1/posts/{id}/comments?limit=20&page=1 +// POST /api/posts/:id/comments -> feed POST /api/v1/posts/{id}/comments +// body { content } -> feed { content, type: "text", status: "published" } +// 401 { error: "sign_in" } without a session. + +export const runtime = "nodejs"; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function GET(req: NextRequest, ctx: Ctx) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + const r = await listComments(sessionId, id); + if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); + return NextResponse.json({ comments: r.comments }); +} + +export async function POST(req: NextRequest, ctx: Ctx) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + let content: unknown = null; + try { + content = ((await req.json()) as { content?: unknown })?.content; + } catch { + content = null; + } + const r = await createComment(sessionId, id, content); + if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); + return NextResponse.json({ comment: r.comment }); +} diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts new file mode 100644 index 0000000..0090194 --- /dev/null +++ b/src/app/api/posts/[id]/like/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { setPostLike } from "@/lib/post-like"; + +// POST /api/posts/:id/like -> feed POST /api/v1/posts/{id}/like +// DELETE /api/posts/:id/like -> feed DELETE /api/v1/posts/{id}/like +// No request body. Returns { isLiked, likesCount }; likesCount is null when the +// feed response carries no count. 401 { error: "sign_in" } without a session. + +export const runtime = "nodejs"; + +type Ctx = { params: Promise<{ id: string }> }; + +async function handle(req: NextRequest, ctx: Ctx, liked: boolean) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + const result = await setPostLike(sessionId, id, liked); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ isLiked: result.isLiked, likesCount: result.likesCount }); +} + +export function POST(req: NextRequest, ctx: Ctx) { + return handle(req, ctx, true); +} + +export function DELETE(req: NextRequest, ctx: Ctx) { + return handle(req, ctx, false); +} diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts new file mode 100644 index 0000000..b9ea408 --- /dev/null +++ b/src/app/api/posts/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { deletePost } from "@/lib/post-delete"; + +// DELETE /api/posts/:id -> feed DELETE /api/v1/posts/{id} +// 401 { error: "sign_in" } without a session. Returns { ok: true } on success. + +export const runtime = "nodejs"; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function DELETE(req: NextRequest, ctx: Ctx) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + const result = await deletePost(sessionId, id); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts new file mode 100644 index 0000000..0164581 --- /dev/null +++ b/src/app/api/posts/route.ts @@ -0,0 +1,26 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { createTextPost } from "@/lib/post-create"; + +// POST /api/posts { content } +// Creates a public text draft on the feed, then publishes it. Returns { id }. +// The create call can report status "draft" for a post already on the home +// feed, so the browser reloads the first page and confirms the id there. +// 401 { error: "sign_in" } without a session. + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + let content: unknown = null; + try { + content = ((await req.json()) as { content?: unknown }).content; + } catch { + content = null; + } + const result = await createTextPost(sessionId, content); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ id: result.id }); +} diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts index 6f7ee4d..7b101d8 100644 --- a/src/app/api/session/route.ts +++ b/src/app/api/session/route.ts @@ -88,6 +88,7 @@ export async function GET(req: NextRequest) { name: session.person.name, email: session.person.email, pictureUrl: session.person.pictureUrl, + feedUserId: session.person.feedUserId ?? null, }); } diff --git a/src/app/globals.css b/src/app/globals.css index f586a20..791fcb0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -35,3 +35,37 @@ button:disabled { opacity: .5; cursor: not-allowed; } @keyframes spin { to { transform: rotate(360deg); } } .new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; } .sentinel { height: 1px; } +.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 14px; font-weight: 400; } +.like-btn:hover:not(:disabled) { color: var(--red); background: #fff3f3; } +.like-btn.liked { color: var(--red); } +.like-btn:disabled { opacity: 1; cursor: default; } +.like-btn:disabled:not(.liked) { color: var(--grey); } +.like-error { color: #a8141b; font-size: 13px; align-self: center; } +.post-stats { align-items: center; } +.composer { background: #fff; border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; } +.composer-label { margin: 0; } +.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid #999; border-radius: 0; resize: vertical; background: #fff; color: var(--ink); } +.composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; } + +.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #eee; } +.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.comment p { margin: 2px 0 0; font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; } +.comment-head { display: flex; gap: 8px; align-items: baseline; font-size: 13px; } +.comment-head time { color: var(--grey); font-size: 12px; } +.comment.removed .comment-removed { color: var(--grey); font-style: italic; } +.comments-empty { margin: 0; color: var(--grey); font-size: 14px; } +.comment-error { margin: 0; color: #a8141b; font-size: 13px; } +.comment-form { display: flex; gap: 8px; } +.comment-form input { flex: 1; margin: 0; } +.comment-form button { margin: 0; } +.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; } +.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; } + +.post-menu { position: relative; margin-left: auto; } +.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; } +.menu { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 6px; background: #fff; border: 1px solid #e3e3e3; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.12); } +.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; } +.menu button:hover { background: #f4f4f4; } +.menu .menu-danger { color: #a8141b; } +.menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; } diff --git a/src/components/App.tsx b/src/components/App.tsx index d2fb73c..2c0a05f 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -38,7 +38,13 @@ export function App() { ); } if (state.kind === "out") { - return setState({ kind: "in", person })} />; + return { + setState({ kind: "in", person }); + // POST /api/session keeps its shape; the feed user id comes from GET. + void loadSession().then((full) => { + if (full) setState({ kind: "in", person: full }); + }); + }} />; } return (
@@ -52,7 +58,7 @@ export function App() { {signingOut ? "Signing out..." : "Sign out"} - setState({ kind: "out" })} /> + setState({ kind: "out" })} />
); } diff --git a/src/components/CommentsPanel.tsx b/src/components/CommentsPanel.tsx new file mode 100644 index 0000000..ab23296 --- /dev/null +++ b/src/components/CommentsPanel.tsx @@ -0,0 +1,61 @@ +import type { CommentItem } from "@/lib/comment-model"; +import { relativeTime } from "@/lib/feed-model"; + +export interface CommentsPanelProps { + comments: CommentItem[]; + loading?: boolean; + /** Error text naming the action that failed. The list stays shown. */ + error?: string | null; + value: string; + busy?: boolean; + onChange?: (v: string) => void; + onSubmit?: () => void; + onRetry?: () => void; + now?: number; +} + +export function CommentsPanel(p: CommentsPanelProps) { + const empty = !p.value.trim(); + return ( +
+ {p.loading ? : null} + {!p.loading && p.comments.length === 0 && !p.error ? ( +

No comments yet.

+ ) : null} +
    + {p.comments.map((c) => ( +
  • +
    + {c.authorName} + +
    + {c.removed ?

    This comment was removed.

    :

    {c.content}

    } +
  • + ))} +
+ {p.error ? ( +

+ {p.error}{" "} + {p.onRetry ? : null} +

+ ) : null} +
{ + e.preventDefault(); + if (!empty && !p.busy) p.onSubmit?.(); + }} + > + p.onChange?.(e.target.value)} + /> + +
+
+ ); +} diff --git a/src/components/CommentsSection.tsx b/src/components/CommentsSection.tsx new file mode 100644 index 0000000..0d13c03 --- /dev/null +++ b/src/components/CommentsSection.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { CommentError, commentErrorText, loadComments, postComment } from "@/lib/comment-client"; +import type { CommentItem } from "@/lib/comment-model"; +import { CommentsPanel } from "./CommentsPanel"; + +/** Opened comments for one post: loads on mount, posts a text comment into the list. */ +export function CommentsSection({ postId, onSignedOut }: { postId: string; onSignedOut?: () => void }) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + + const fail = useCallback( + (e: unknown, action: "load" | "post") => { + if (e instanceof CommentError && e.signedOut) onSignedOut?.(); + setError(commentErrorText(action)); + }, + [onSignedOut], + ); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setComments(await loadComments(postId)); + } catch (e) { + fail(e, "load"); + } finally { + setLoading(false); + } + }, [postId, fail]); + + useEffect(() => { + void load(); + }, [load]); + + const submit = useCallback(async () => { + if (!value.trim() || busy) return; + setBusy(true); + setError(null); + try { + setComments(await postComment(postId, value, comments)); + setValue(""); + } catch (e) { + fail(e, "post"); // the list and the typed text stay in place + } finally { + setBusy(false); + } + }, [postId, value, busy, comments, fail]); + + return ( + void submit()} + onRetry={error === commentErrorText("load") ? () => void load() : undefined} + /> + ); +} diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx new file mode 100644 index 0000000..02b367b --- /dev/null +++ b/src/components/Composer.tsx @@ -0,0 +1,36 @@ +import type { FormEvent } from "react"; + +export interface ComposerProps { + value: string; + busy: boolean; + error: boolean; + onChange: (value: string) => void; + onSubmit: () => void; +} + +export function Composer({ value, busy, error, onChange, onSubmit }: ComposerProps) { + const empty = value.trim().length === 0; + function submit(e: FormEvent) { + e.preventDefault(); + if (!empty && !busy) onSubmit(); + } + return ( +
+ +