From d493b35eded927a4e7f1bc55734120106b1840f2 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 14:49:03 -0600 Subject: [PATCH] t3xbu10dr: Publish video posts through the media pipeline and upgrade thumbnails to the player --- src/app/api/posts/route.ts | 6 ++--- src/app/globals.css | 2 ++ src/components/Composer.tsx | 23 +++++++++++------ src/components/Feed.tsx | 8 +++--- src/components/PostCard.tsx | 4 +++ src/lib/feed-model.ts | 24 ++++++++++++++++- src/lib/post-media.ts | 48 ++++++++++++++++++++++++++++++++-- tests/feed-view.test.ts | 34 ++++++++++++++++++++++++- tests/post-media.test.ts | 51 ++++++++++++++++++++++++++++++++++++- 9 files changed, 181 insertions(+), 19 deletions(-) diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index a87e1b8..ebc6194 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,11 +1,11 @@ import { NextResponse, type NextRequest } from "next/server"; import { SESSION_COOKIE } from "@/lib/cookie"; import { createTextPost } from "@/lib/post-create"; -import { createImagePost } from "@/lib/post-media"; +import { createMediaPost } from "@/lib/post-media"; // POST /api/posts // - JSON { content } -> public text post -// - multipart/form-data content+file -> public image post (one file) +// - 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 @@ -31,7 +31,7 @@ export async function POST(req: NextRequest) { } catch { return NextResponse.json({ error: "bad_file" }, { status: 400 }); } - const result = await createImagePost(sessionId, content, file); + const result = await createMediaPost(sessionId, content, file); if (!result.ok) { return NextResponse.json({ error: result.error }, { status: result.status }); } diff --git a/src/app/globals.css b/src/app/globals.css index bc563e0..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); } @@ -52,6 +53,7 @@ button:disabled { opacity: .5; cursor: not-allowed; } .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; } diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index aa2081a..c0e4d3c 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,27 +1,27 @@ "use client"; -import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; +import { useEffect, useState, type ChangeEvent, type FormEvent } from "react"; export interface ComposerProps { value: string; busy: boolean; error: boolean; - /** The selected image file, when the image picker is wired. */ + /** The selected image or video file, when the pickers are wired. */ file?: File | null; onChange: (value: string) => void; - /** When present, the composer shows an image picker and reports the choice. */ + /** 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 an image. + // A post needs either text or a file. const canSubmit = (!empty || !!file) && !busy; - const inputRef = useRef(null); const [preview, setPreview] = useState(null); // Build a preview URL client-side only, and revoke it when the file changes. @@ -42,11 +42,13 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on 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); - if (inputRef.current) inputRef.current.value = ""; } + const isVideo = !!file && file.type.startsWith("video/"); return (
@@ -64,16 +66,21 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on + {file ? (
- {preview ? ( + {preview && isVideo ? ( +
+ ) : m.kind === "video" ? ( +
+ Video is processing +
) : null, )}