diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 0164581..ebc6194 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,17 +1,43 @@ 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 { 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. +// 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; diff --git a/src/app/globals.css b/src/app/globals.css index 791fcb0..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); } @@ -47,6 +48,15 @@ button:disabled { opacity: .5; cursor: not-allowed; } .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; } diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 02b367b..c0e4d3c 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,19 +1,55 @@ -import type { FormEvent } from "react"; +"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; } -export function Composer({ value, busy, error, onChange, onSubmit }: ComposerProps) { +// 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 (!empty && !busy) onSubmit(); + 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 (
@@ -25,10 +61,41 @@ export function Composer({ value, busy, error, onChange, onSubmit }: ComposerPro disabled={busy} onChange={(e) => onChange(e.target.value)} /> + {onFile ? ( +
+ + + {file ? ( +
+ {preview && isVideo ? ( +
+ ) : null} +
+ ) : null} {error ? (

Publishing the post failed. Your text is still here.

) : null} -
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index da0fe97..d6c4aa1 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client"; import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; -import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model"; +import { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model"; import { PostError, publishAndConfirm } from "@/lib/post-client"; import { DeleteError, sendDelete } from "@/lib/delete-client"; import { FeedView, type FeedStatus } from "./FeedView"; @@ -22,6 +22,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee const [deleteBusy, setDeleteBusy] = useState>(new Set()); const [deleteErrors, setDeleteErrors] = useState>(new Set()); const [draft, setDraft] = useState(""); + const [file, setFile] = useState(null); const [posting, setPosting] = useState(false); const [postFailed, setPostFailed] = useState(false); // Id of a post already created but not yet seen on the first page. A retry @@ -105,7 +106,9 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee const poll = async () => { try { const page = await fetchFeedPage(null); - const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key)); + const all = toCardPosts(page.items); + setPosts((cur) => upgradeVideos(cur, all)); + const fresh = all.filter((p) => !shown.current.has(p.key)); if (fresh.length) setPending((cur) => mergePosts(cur, fresh)); } catch (e) { fail(e); @@ -187,22 +190,24 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee const publish = useCallback(async () => { const text = draft.trim(); - if (!text || posting) return; + // A post needs either text or a file. + if ((!text && !file) || posting) return; setPosting(true); setPostFailed(false); try { - const page = await publishAndConfirm(text, unconfirmed.current); + const page = await publishAndConfirm(text, file, unconfirmed.current); setPosts(mergePosts(toCardPosts(page.items))); setNextCursor(page.nextCursor); setPending([]); setDraft(""); + setFile(null); } catch (e) { if (e instanceof PostError && e.signedOut) onSignedOut(); setPostFailed(true); } finally { setPosting(false); } - }, [draft, posting, onSignedOut]); + }, [draft, file, posting, onSignedOut]); function changeDraft(v: string) { // New text is a new post; forget a stale unconfirmed id. @@ -210,6 +215,12 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee setDraft(v); } + function changeFile(f: File | null) { + // A different attachment is a new post; forget a stale unconfirmed id. + unconfirmed.current = { id: null }; + setFile(f); + } + function showNew() { setPosts((cur) => mergePosts(cur, pending)); setPending([]); @@ -233,7 +244,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee deleteBusy={deleteBusy} deleteErrors={deleteErrors} onSignedOut={onSignedOut} - composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }} + composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }} sentinelRef={sentinel} /> ); diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index e1f2c0f..6616418 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -96,6 +96,10 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne {/* eslint-disable-next-line @next/next/no-img-element */} {m.kind + ) : m.kind === "video" ? ( +
+ Video is processing +
) : null, )}