feat(05): Phase 5 — short-form video via Mux
Schema: - Add VideoPost model (muxUploadId, muxAssetId, muxPlaybackId, status, durationSecs, aspectRatio) linked 1-to-1 with Post - Add VideoStatus enum (PROCESSING/READY/ERROR) - Add PostType.VIDEO Backend: - src/lib/mux.ts — singleton Mux client + webhook secret - videos router: createUpload (Mux direct upload URL + PROCESSING post), getByPostId (status polling), updateCaption - POST /api/webhooks/mux: verifies signature; handles video.upload.asset_created (store muxAssetId), video.asset.ready (set READY + playbackId + fan-out to feed), video.asset.errored (set ERROR) - feed.ts + posts.byPetId: include videoPost in all post queries UI: - VideoUploadForm: drop zone → XHR PUT to Mux upload URL with progress bar; caption field; transitions to "Processing…" on success - VideoCard: PROCESSING shows spinner; READY renders MuxPlayer (HLS, orange accent); polls every 5s until status changes - PostTypeSheet: "Video Post" option added (between Photo and Milestone) - PostCard: VIDEO type renders VideoCard instead of image carousel Env vars: MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET, NEXT_PUBLIC_APP_URL — placeholders added to .env.local Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
08fd0dfb71
commit
60366691e8
13 files changed
+758
-7
No files matched your search
Generated
+146
-3
@@ -13,6 +13,8 @@
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@clerk/nextjs": "^7.4.3",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mux/mux-node": "^14.1.1",
|
||||
"@mux/mux-player-react": "^3.13.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@prisma/extension-accelerate": "^3.0.1",
|
||||
@@ -2975,6 +2977,83 @@
|
||||
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mux/mux-data-google-ima": {
|
||||
"version": "0.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@mux/mux-data-google-ima/-/mux-data-google-ima-0.3.17.tgz",
|
||||
"integrity": "sha512-4wpH6dYybyZhqLn9qGn/+67Z8MZnQRAdqTFEEZw2bx61M9q01uPYYHxd8qwOnYtUGEeafsdTwVHVxKHGD3oc1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mux-embed": "5.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mux/mux-node": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mux/mux-node/-/mux-node-14.1.1.tgz",
|
||||
"integrity": "sha512-Am79fhXU6A15zef1y+2UKfbIQ/UMGiVOjKKV3fOAqR8IS0A4HbL6WLDs0rNlMRHrefvASAftM2NWwLLBU3Peaw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"mux-mux-node": "bin/cli"
|
||||
}
|
||||
},
|
||||
"node_modules/@mux/mux-player": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@mux/mux-player/-/mux-player-3.13.0.tgz",
|
||||
"integrity": "sha512-vh4CIMahUa29gys+mlfsOFKYKAKXxE07jSWk9WZxkdpGBW1fKfCQXlNxBoAIQlPa9Uk4MZuM3HVgqdW6aTuaZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mux/mux-video": "0.31.0",
|
||||
"@mux/playback-core": "0.35.0",
|
||||
"media-chrome": "~4.19.0",
|
||||
"player.style": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mux/mux-player-react": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@mux/mux-player-react/-/mux-player-react-3.13.0.tgz",
|
||||
"integrity": "sha512-7IkImo1H3rUYeuWHI/L0L7sGUqBvZCvptx3+4igc+P/V3WgqNFjOyQlcyxzxgXNtNNhGmkn5NaF3TU9DPbOmAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mux/mux-player": "3.13.0",
|
||||
"@mux/playback-core": "0.35.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0",
|
||||
"react": "^17.0.2 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0",
|
||||
"react-dom": "^17.0.2 || ^17.0.2-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mux/mux-video": {
|
||||
"version": "0.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@mux/mux-video/-/mux-video-0.31.0.tgz",
|
||||
"integrity": "sha512-DvO2GynIJhPDc0LMuWvC144lCF+E07NI+chrg/vcRQlCf2fPdNNqCSMoaRdWu2S2v/Orsc85giT9K6GRbjbzgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mux/mux-data-google-ima": "^0.3.4",
|
||||
"@mux/playback-core": "0.35.0",
|
||||
"castable-video": "~1.1.13",
|
||||
"custom-media-element": "~1.4.6",
|
||||
"media-tracks": "~0.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@mux/playback-core": {
|
||||
"version": "0.35.0",
|
||||
"resolved": "https://registry.npmjs.org/@mux/playback-core/-/playback-core-0.35.0.tgz",
|
||||
"integrity": "sha512-7Zi1EJ9sQNIUlQVBjJCXV0CB+rUVEsU3vNRElRV4xnD7dbpoioIAhe1SjZNBifjnK5aBKLDwQHypbnL3Cw3a5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hls.js": "~1.6.15",
|
||||
"mux-embed": "^5.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -6145,6 +6224,24 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/castable-video": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/castable-video/-/castable-video-1.1.16.tgz",
|
||||
"integrity": "sha512-wBhe2dZu2afhewL3EaGgVYTyDsa9HvNhY98clMZkNzDrLelOValSrTaoMos9YX7PPBCrgpd1j6YmNyyI2Vbq3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"custom-media-element": "~1.4.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ce-la-react": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ce-la-react/-/ce-la-react-0.3.2.tgz",
|
||||
"integrity": "sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -6512,6 +6609,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/custom-media-element": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/custom-media-element/-/custom-media-element-1.4.6.tgz",
|
||||
"integrity": "sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -8588,6 +8691,12 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.16",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
@@ -10006,7 +10115,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -10115,6 +10223,21 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-chrome": {
|
||||
"version": "4.19.2",
|
||||
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.19.2.tgz",
|
||||
"integrity": "sha512-4ai1ITN8wBhwugQcRgqe3tN0z6OSKGOXqHLNrS04MgKFfsLqu6Dm8MPq02pI9Y9ZKoXtFjIl85jOryIW9es3BA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ce-la-react": "^0.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/media-tracks": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/media-tracks/-/media-tracks-0.3.5.tgz",
|
||||
"integrity": "sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
@@ -10304,6 +10427,12 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mux-embed": {
|
||||
"version": "5.18.1",
|
||||
"resolved": "https://registry.npmjs.org/mux-embed/-/mux-embed-5.18.1.tgz",
|
||||
"integrity": "sha512-ePsHjiEKY+FgrSBiMmaF+LOtTQSSBWv/1zqpREQFN96JE93xlsArT/MEi30yKOE06MgjOlL70YI750molu3y7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
|
||||
@@ -11178,6 +11307,22 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/player.style": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/player.style/-/player.style-0.3.4.tgz",
|
||||
"integrity": "sha512-5O9bkbq0APQIkhptyZwp0gUgheCeImGPFo4hvPF2M5xBmgsUYzsUPHCfMye2xyeRE1zvQBKtziaC3jPgnHE1tw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
".",
|
||||
"site",
|
||||
"examples/*",
|
||||
"scripts/*",
|
||||
"themes/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"media-chrome": "~4.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -11377,7 +11522,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -11566,7 +11710,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@clerk/nextjs": "^7.4.3",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mux/mux-node": "^14.1.1",
|
||||
"@mux/mux-player-react": "^3.13.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@prisma/extension-accelerate": "^3.0.1",
|
||||
|
||||
@@ -72,6 +72,13 @@ enum PostType {
|
||||
PHOTO
|
||||
MILESTONE
|
||||
REPOST
|
||||
VIDEO
|
||||
}
|
||||
|
||||
enum VideoStatus {
|
||||
PROCESSING
|
||||
READY
|
||||
ERROR
|
||||
}
|
||||
|
||||
enum MilestoneType {
|
||||
@@ -124,6 +131,9 @@ model Post {
|
||||
// Phase 4 back-relations
|
||||
notifications Notification[]
|
||||
|
||||
// Phase 5 back-relation
|
||||
videoPost VideoPost?
|
||||
|
||||
@@index([petId, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
@@ -269,6 +279,23 @@ model PostHashtag {
|
||||
@@index([hashtagId])
|
||||
}
|
||||
|
||||
// ─── Phase 5 Models ──────────────────────────────────────────────────────────
|
||||
|
||||
model VideoPost {
|
||||
id String @id @default(cuid())
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
postId String @unique
|
||||
muxUploadId String? // Mux direct upload ID — set on creation
|
||||
muxAssetId String? // Mux asset ID — set by video.upload.asset_created webhook
|
||||
muxPlaybackId String? // Mux playback ID — set by video.asset.ready webhook
|
||||
status VideoStatus @default(PROCESSING)
|
||||
durationSecs Float? // populated by webhook
|
||||
aspectRatio String? // e.g. "16:9" from Mux
|
||||
|
||||
@@index([muxUploadId])
|
||||
@@index([muxAssetId])
|
||||
}
|
||||
|
||||
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
|
||||
|
||||
enum NotificationType {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { mux, MUX_WEBHOOK_SECRET } from "@/lib/mux";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { fanOutPost } from "@/lib/feed-helpers";
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/mux
|
||||
*
|
||||
* Handles Mux webhook events for video lifecycle:
|
||||
* - video.upload.asset_created → store muxAssetId on VideoPost
|
||||
* - video.asset.ready → mark READY + store playbackId + fan-out to feed
|
||||
* - video.asset.errored → mark ERROR
|
||||
*
|
||||
* Signature verification uses Mux.Webhooks.unwrap() — returns 400 on mismatch.
|
||||
* Configure in Mux dashboard: Webhooks → Add endpoint → https://yourdomain/api/webhooks/mux
|
||||
* Subscribe to: video.upload.asset_created, video.asset.ready, video.asset.errored
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const signature = req.headers.get("mux-signature") ?? "";
|
||||
|
||||
// Verify signature — skip verification in dev if secret not set
|
||||
if (MUX_WEBHOOK_SECRET) {
|
||||
try {
|
||||
mux.webhooks.verifySignature(body, req.headers as never, MUX_WEBHOOK_SECRET);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
let event: { type: string; data: Record<string, unknown> };
|
||||
try {
|
||||
event = JSON.parse(body) as typeof event;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { type, data } = event;
|
||||
|
||||
switch (type) {
|
||||
case "video.upload.asset_created": {
|
||||
const uploadId = data.id as string;
|
||||
const assetId = (data.asset_id ?? (data.asset as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (!uploadId || !assetId) break;
|
||||
|
||||
await prisma.videoPost.updateMany({
|
||||
where: { muxUploadId: uploadId },
|
||||
data: { muxAssetId: assetId },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "video.asset.ready": {
|
||||
const assetId = data.id as string;
|
||||
const playbackIds = data.playback_ids as Array<{ id: string; policy: string }> | undefined;
|
||||
const publicPlayback = playbackIds?.find((p) => p.policy === "public");
|
||||
const playbackId = publicPlayback?.id;
|
||||
const durationSecs = data.duration as number | undefined;
|
||||
const aspectRatio = data.aspect_ratio as string | undefined;
|
||||
|
||||
if (!assetId || !playbackId) break;
|
||||
|
||||
const videoPost = await prisma.videoPost.findFirst({
|
||||
where: { muxAssetId: assetId },
|
||||
include: { post: { select: { id: true, petId: true, createdAt: true } } },
|
||||
});
|
||||
if (!videoPost) break;
|
||||
|
||||
await prisma.videoPost.update({
|
||||
where: { id: videoPost.id },
|
||||
data: {
|
||||
muxPlaybackId: playbackId,
|
||||
status: "READY",
|
||||
durationSecs: durationSecs ?? null,
|
||||
aspectRatio: aspectRatio ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Fan-out to followers now that the video is playable
|
||||
await fanOutPost(
|
||||
redis,
|
||||
videoPost.post.id,
|
||||
videoPost.post.petId,
|
||||
videoPost.post.createdAt,
|
||||
prisma
|
||||
).catch(() => {});
|
||||
break;
|
||||
}
|
||||
|
||||
case "video.asset.errored": {
|
||||
const assetId = data.id as string;
|
||||
if (!assetId) break;
|
||||
|
||||
await prisma.videoPost.updateMany({
|
||||
where: { muxAssetId: assetId },
|
||||
data: { status: "ERROR" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Unhandled event type — acknowledge without error
|
||||
break;
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { getMediaUrl } from "@/lib/media-url";
|
||||
import { parseCaption } from "@/lib/parse-caption";
|
||||
import { PawRing } from "@/components/stories/PawRing";
|
||||
import { MilestoneCard } from "@/components/feed/MilestoneCard";
|
||||
import { VideoCard } from "@/components/video/VideoCard";
|
||||
import { PawButton } from "@/components/feed/PawButton";
|
||||
import { PawBackButton } from "@/components/feed/PawBackButton";
|
||||
import { CommentSheet } from "@/components/feed/CommentSheet";
|
||||
@@ -99,6 +100,10 @@ export type PostCardPost = {
|
||||
reposterPetId: string;
|
||||
originalPost: OriginalPostContent;
|
||||
} | null;
|
||||
videoPost?: {
|
||||
status: "PROCESSING" | "READY" | "ERROR";
|
||||
muxPlaybackId: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type PostCardProps = {
|
||||
@@ -297,8 +302,17 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Image area */}
|
||||
{images.length > 0 && (
|
||||
{/* Video area — VIDEO posts */}
|
||||
{post.type === "VIDEO" && post.videoPost && (
|
||||
<VideoCard
|
||||
postId={post.id}
|
||||
initialStatus={post.videoPost.status}
|
||||
initialPlaybackId={post.videoPost.muxPlaybackId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image area — PHOTO posts */}
|
||||
{post.type !== "VIDEO" && images.length > 0 && (
|
||||
<>
|
||||
{isCarousel ? (
|
||||
/* Carousel: 2–10 images — Embla via shadcn (PHOTO-02) */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Camera, BookOpen, Trophy } from "lucide-react";
|
||||
import { Camera, BookOpen, Trophy, Video } from "lucide-react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { PhotoPostForm } from "@/components/post-creation/PhotoPostForm";
|
||||
import { StoryForm } from "@/components/stories/StoryForm";
|
||||
import { MilestoneForm } from "@/components/post-creation/MilestoneForm";
|
||||
import { VideoUploadForm } from "@/components/video/VideoUploadForm";
|
||||
|
||||
/**
|
||||
* PostTypeSheet — bottom Sheet with 3 post type choices (D-01).
|
||||
@@ -40,7 +41,7 @@ interface PostTypeSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type PostType = "photo" | "story" | "milestone" | null;
|
||||
type PostType = "photo" | "story" | "milestone" | "video" | null;
|
||||
|
||||
export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
|
||||
const [selectedType, setSelectedType] = useState<PostType>(null);
|
||||
@@ -51,6 +52,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
|
||||
|
||||
// TODO(02-06): MilestoneForm — open milestone dialog
|
||||
const [milestoneDialogOpen, setMilestoneDialogOpen] = useState(false);
|
||||
const [videoDialogOpen, setVideoDialogOpen] = useState(false);
|
||||
|
||||
function handleStoryClose() {
|
||||
setStoryDialogOpen(false);
|
||||
@@ -72,6 +74,8 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
|
||||
setStoryDialogOpen(true);
|
||||
} else if (type === "milestone") {
|
||||
setMilestoneDialogOpen(true);
|
||||
} else if (type === "video") {
|
||||
setVideoDialogOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +114,16 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
|
||||
<span>Story</span>
|
||||
</Button>
|
||||
|
||||
{/* Video Post option */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full min-h-[52px] justify-start gap-3 text-sm font-medium"
|
||||
onClick={() => handleSelectType("video")}
|
||||
>
|
||||
<Video className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
|
||||
<span>Video Post</span>
|
||||
</Button>
|
||||
|
||||
{/* Milestone option — seam for 02-06 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -152,6 +166,16 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
|
||||
<MilestoneForm onClose={handleMilestoneClose} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Video Dialog */}
|
||||
<Dialog open={videoDialogOpen} onOpenChange={setVideoDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="text-xl font-semibold">New Video</DialogTitle>
|
||||
</DialogHeader>
|
||||
<VideoUploadForm onClose={() => { setVideoDialogOpen(false); setSelectedType(null); }} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import MuxPlayer from "@mux/mux-player-react";
|
||||
|
||||
interface VideoCardProps {
|
||||
postId: string;
|
||||
/** Initial status from the feed/profile query — avoids a redundant fetch when already READY */
|
||||
initialStatus: "PROCESSING" | "READY" | "ERROR";
|
||||
initialPlaybackId?: string | null;
|
||||
}
|
||||
|
||||
export function VideoCard({ postId, initialStatus, initialPlaybackId }: VideoCardProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Poll while PROCESSING — stop once READY or ERROR
|
||||
const { data } = useQuery({
|
||||
...trpc.videos.getByPostId.queryOptions({ postId }),
|
||||
initialData: initialPlaybackId
|
||||
? { id: "", postId, muxUploadId: null, muxAssetId: null, muxPlaybackId: initialPlaybackId, status: initialStatus as "PROCESSING" | "READY" | "ERROR", durationSecs: null, aspectRatio: null }
|
||||
: undefined,
|
||||
enabled: initialStatus === "PROCESSING",
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "PROCESSING" ? 5_000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const status = data?.status ?? initialStatus;
|
||||
const playbackId = data?.muxPlaybackId ?? initialPlaybackId;
|
||||
|
||||
// Invalidate the parent feed/post query once the video becomes ready
|
||||
useEffect(() => {
|
||||
if (status === "READY" && initialStatus === "PROCESSING") {
|
||||
void queryClient.invalidateQueries();
|
||||
}
|
||||
}, [status, initialStatus, queryClient]);
|
||||
|
||||
if (status === "PROCESSING") {
|
||||
return (
|
||||
<div className="aspect-video bg-muted flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-orange-500" />
|
||||
<p className="text-sm">Processing video…</p>
|
||||
<p className="text-xs">This usually takes less than a minute</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "ERROR") {
|
||||
return (
|
||||
<div className="aspect-video bg-muted flex flex-col items-center justify-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
<p className="text-sm">Video processing failed</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!playbackId) return null;
|
||||
|
||||
return (
|
||||
<MuxPlayer
|
||||
playbackId={playbackId}
|
||||
streamType="on-demand"
|
||||
style={{ aspectRatio: "16/9", width: "100%" }}
|
||||
accentColor="#f97316"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Video, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const ACCEPTED_TYPES = ["video/mp4", "video/quicktime", "video/webm"];
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
type UploadState = "idle" | "uploading" | "processing" | "error";
|
||||
|
||||
interface VideoUploadFormProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [caption, setCaption] = useState("");
|
||||
const [uploadState, setUploadState] = useState<UploadState>("idle");
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const createUploadMutation = useMutation(
|
||||
trpc.videos.createUpload.mutationOptions()
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
toast.error("Upload an MP4, MOV, or WebM video.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error("Video must be under 500 MB.");
|
||||
return;
|
||||
}
|
||||
setFile(file);
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
setUploadState("idle");
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFileSelect(f);
|
||||
e.target.value = "";
|
||||
},
|
||||
[handleFileSelect]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) handleFileSelect(f);
|
||||
},
|
||||
[handleFileSelect]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!file || !activePetId) return;
|
||||
|
||||
setUploadState("uploading");
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const { uploadUrl } = await createUploadMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
caption: caption.trim() || null,
|
||||
});
|
||||
|
||||
// PUT file directly to Mux upload URL
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
setUploadProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`Upload failed: ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||
xhr.open("PUT", uploadUrl ?? "");
|
||||
// Mux direct upload accepts the video without a specific Content-Type header
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
setUploadState("processing");
|
||||
toast.success("Video uploaded — processing in the background");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setUploadState("error");
|
||||
toast.error("Upload failed. Please try again.");
|
||||
}
|
||||
}, [file, activePetId, caption, createUploadMutation, onClose]);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Drop zone */}
|
||||
{!file ? (
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-10 text-center cursor-pointer hover:border-muted-foreground/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
aria-label="Select video"
|
||||
>
|
||||
<Video className="h-10 w-10 text-muted-foreground mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm font-medium">Select a video</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
MP4, MOV or WebM · up to 500 MB · max 3 minutes
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Video preview */
|
||||
<div className="relative rounded-lg overflow-hidden bg-black aspect-video">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<video
|
||||
src={previewUrl ?? undefined}
|
||||
controls
|
||||
className="w-full h-full object-contain"
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Change video button */}
|
||||
{file && uploadState === "idle" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFile(null); setPreviewUrl(null); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Choose a different video
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Caption */}
|
||||
{file && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="video-caption">Caption</Label>
|
||||
<Textarea
|
||||
id="video-caption"
|
||||
placeholder="Write a caption…"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
className="text-sm resize-none"
|
||||
disabled={uploadState !== "idle"}
|
||||
/>
|
||||
{caption.length > 450 && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{500 - caption.length} left
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploadState === "uploading" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Uploading…</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-orange-500 transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState === "processing" && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
Uploaded — video is being processed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState === "error" && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Upload failed — please try again
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={uploadState === "uploading"}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || uploadState !== "idle" || createUploadMutation.isPending}
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
{uploadState === "uploading" ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> Uploading…</>
|
||||
) : (
|
||||
"Post video"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import "server-only";
|
||||
import Mux from "@mux/mux-node";
|
||||
|
||||
// Singleton Mux client — MUX_TOKEN_ID and MUX_TOKEN_SECRET must be set in .env.local
|
||||
// Get credentials at dashboard.mux.com → Settings → Access Tokens
|
||||
export const mux = new Mux({
|
||||
tokenId: process.env.MUX_TOKEN_ID ?? "",
|
||||
tokenSecret: process.env.MUX_TOKEN_SECRET ?? "",
|
||||
});
|
||||
|
||||
export const MUX_WEBHOOK_SECRET = process.env.MUX_WEBHOOK_SECRET ?? "";
|
||||
@@ -14,6 +14,7 @@ import { repostsRouter } from "@/trpc/routers/reposts";
|
||||
import { exploreRouter } from "@/trpc/routers/explore";
|
||||
import { searchRouter } from "@/trpc/routers/search";
|
||||
import { notificationsRouter } from "@/trpc/routers/notifications";
|
||||
import { videosRouter } from "@/trpc/routers/videos";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -31,6 +32,7 @@ export const appRouter = router({
|
||||
explore: exploreRouter,
|
||||
search: searchRouter,
|
||||
notifications: notificationsRouter,
|
||||
videos: videosRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -58,6 +58,7 @@ export const feedRouter = router({
|
||||
images: { orderBy: { position: "asc" } as const },
|
||||
milestone: true,
|
||||
_count: { select: { reactions: true, comments: true } },
|
||||
videoPost: { select: { status: true, muxPlaybackId: true } },
|
||||
repost: {
|
||||
include: {
|
||||
originalPost: {
|
||||
|
||||
@@ -216,6 +216,7 @@ export const postsRouter = router({
|
||||
pet: { select: { id: true, name: true, avatarKey: true } },
|
||||
milestone: true,
|
||||
_count: { select: { reactions: true, comments: true } },
|
||||
videoPost: { select: { status: true, muxPlaybackId: true } },
|
||||
repost: {
|
||||
include: {
|
||||
originalPost: {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import { mux } from "@/lib/mux";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
const MAX_VIDEO_DURATION_SECS = 180; // 3 min hard cap — Mux rejects longer via passthrough check
|
||||
|
||||
async function assertPetOwnership(prisma: PrismaClient, petId: string, userId: string) {
|
||||
const pet = await prisma.pet.findUnique({ where: { id: petId } });
|
||||
if (!pet || pet.ownerId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
return pet;
|
||||
}
|
||||
|
||||
export const videosRouter = router({
|
||||
/**
|
||||
* Create a Mux direct upload URL + a PROCESSING Post/VideoPost row.
|
||||
* The client PUTs the video file directly to Mux — never through our server.
|
||||
* Returns { uploadUrl, postId } — postId used for polling + caption update.
|
||||
*/
|
||||
createUpload: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
petId: z.string().min(1),
|
||||
caption: z.string().max(500).nullable().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
if (!process.env.MUX_TOKEN_ID || !process.env.MUX_TOKEN_SECRET) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Video upload is not configured. Add MUX_TOKEN_ID and MUX_TOKEN_SECRET to .env.local.",
|
||||
});
|
||||
}
|
||||
|
||||
// Create Mux direct upload — client PUTs directly to the returned URL
|
||||
const upload = await mux.video.uploads.create({
|
||||
cors_origin: process.env.NEXT_PUBLIC_APP_URL ?? "*",
|
||||
new_asset_settings: {
|
||||
playback_policy: ["public"],
|
||||
// max_resolution_tier tells Mux to cap at 1080p (cost control)
|
||||
max_resolution_tier: "1080p",
|
||||
passthrough: "", // will be set after post creation
|
||||
},
|
||||
});
|
||||
|
||||
// Create Post + VideoPost in one transaction
|
||||
const post = await ctx.prisma.post.create({
|
||||
data: {
|
||||
petId: input.petId,
|
||||
type: "VIDEO",
|
||||
caption: input.caption ?? null,
|
||||
videoPost: {
|
||||
create: {
|
||||
muxUploadId: upload.id,
|
||||
status: "PROCESSING",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { videoPost: true },
|
||||
});
|
||||
|
||||
// Webhook correlation: we look up VideoPost by muxUploadId — no passthrough update needed
|
||||
|
||||
return {
|
||||
uploadUrl: upload.url,
|
||||
postId: post.id,
|
||||
muxUploadId: upload.id,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the video post status — used to poll while video is PROCESSING.
|
||||
* Returns the VideoPost record so the UI can transition to the player when READY.
|
||||
*/
|
||||
getByPostId: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const videoPost = await ctx.prisma.videoPost.findUnique({
|
||||
where: { postId: input.postId },
|
||||
});
|
||||
if (!videoPost) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
return videoPost;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update caption after upload (post was created with optional caption at upload time).
|
||||
*/
|
||||
updateCaption: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1), caption: z.string().max(500).nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const post = await ctx.prisma.post.findUnique({
|
||||
where: { id: input.postId },
|
||||
select: { pet: { select: { ownerId: true } } },
|
||||
});
|
||||
if (!post || post.pet.ownerId !== ctx.userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
await ctx.prisma.post.update({
|
||||
where: { id: input.postId },
|
||||
data: { caption: input.caption },
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user