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..ebc6194 --- /dev/null +++ b/src/app/api/posts/route.ts @@ -0,0 +1,52 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { createTextPost } from "@/lib/post-create"; +import { createMediaPost } from "@/lib/post-media"; + +// POST /api/posts +// - JSON { content } -> public text post +// - multipart/form-data content+file -> public image or video post (one file) +// Both create a public draft on the feed, then publish it, and return { id }. +// For an image the server also uploads the bytes to a presigned URL and confirms +// the upload; the browser never receives the feed token. 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; + const contentType = req.headers.get("content-type") ?? ""; + + if (contentType.includes("multipart/form-data")) { + let content = ""; + let file: File | null = null; + try { + const form = await req.formData(); + const c = form.get("content"); + content = typeof c === "string" ? c : ""; + const f = form.get("file"); + file = f instanceof File ? f : null; + } catch { + return NextResponse.json({ error: "bad_file" }, { status: 400 }); + } + const result = await createMediaPost(sessionId, content, file); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ id: result.id }); + } + + 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..d6e1e15 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -25,6 +25,7 @@ button:disabled { opacity: .5; cursor: not-allowed; } .post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; } .post-media img { display: block; width: 100%; height: auto; border-radius: 8px; } .post-video { background: #000; border-radius: 8px; overflow: hidden; } +.post-video-pending { color: #fff; padding: 48px 12px; text-align: center; } .post-video video { display: block; width: 100%; max-height: 520px; background: #000; } .post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; } .heart { color: var(--red); } @@ -35,3 +36,46 @@ 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; } +.composer-media { display: flex; flex-direction: column; gap: 8px; } +.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; } +.composer-image span { padding: 6px 12px; border: 1px solid #999; background: #fafafa; } +.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; } +.composer-preview { display: flex; align-items: center; gap: 10px; } +.composer-preview video { width: 96px; height: 64px; object-fit: cover; border: 1px solid var(--line); background: #000; } +.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); } +.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.composer-remove { margin: 0; padding: 6px 12px; 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..c0e4d3c --- /dev/null +++ b/src/components/Composer.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useState, type ChangeEvent, type FormEvent } from "react"; + +export interface ComposerProps { + value: string; + busy: boolean; + error: boolean; + /** The selected image or video file, when the pickers are wired. */ + file?: File | null; + onChange: (value: string) => void; + /** When present, the composer shows image and video pickers and reports the choice. */ + onFile?: (file: File | null) => void; + onSubmit: () => void; +} + +// Content types the picker offers (spec: allowed image types). +const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic"; +const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm"; + +export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit }: ComposerProps) { + const empty = value.trim().length === 0; + // A post needs either text or a file. + const canSubmit = (!empty || !!file) && !busy; + const [preview, setPreview] = useState(null); + + // Build a preview URL client-side only, and revoke it when the file changes. + useEffect(() => { + if (!file) { + setPreview(null); + return; + } + const url = URL.createObjectURL(file); + setPreview(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + function submit(e: FormEvent) { + e.preventDefault(); + if (canSubmit) onSubmit(); + } + function pick(e: ChangeEvent) { + const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null; + onFile?.(chosen); + // One file per post: picking one kind clears the other input. + e.target.value = ""; + } + function remove() { + onFile?.(null); + } + const isVideo = !!file && file.type.startsWith("video/"); + + return ( +
+ +