diff --git a/app/routeTree.gen.ts b/app/routeTree.gen.ts index 7e1e0a2..c3fa1e5 100644 --- a/app/routeTree.gen.ts +++ b/app/routeTree.gen.ts @@ -20,7 +20,10 @@ import { Route as WatchPublicIdRouteImport } from './routes/watch.$publicId' import { Route as SignUpSplatRouteImport } from './routes/sign-up.$' import { Route as SignInSplatRouteImport } from './routes/sign-in.$' import { Route as ShareTokenRouteImport } from './routes/share.$token' +import { Route as ProjectShareTokenRouteImport } from './routes/project-share.$token' import { Route as InviteTokenRouteImport } from './routes/invite.$token' +import { Route as ProjectShareTokenIndexRouteImport } from './routes/project-share.$token.index' +import { Route as ProjectShareTokenVideoIdRouteImport } from './routes/project-share.$token.$videoId' import { Route as ForVideoEditorsRouteImport } from './routes/for.video-editors' import { Route as ForAgenciesRouteImport } from './routes/for.agencies' import { Route as DashboardTeamSlugRouteImport } from './routes/dashboard/$teamSlug' @@ -87,6 +90,21 @@ const ShareTokenRoute = ShareTokenRouteImport.update({ path: '/share/$token', getParentRoute: () => rootRouteImport, } as any) +const ProjectShareTokenRoute = ProjectShareTokenRouteImport.update({ + id: '/project-share/$token', + path: '/project-share/$token', + getParentRoute: () => rootRouteImport, +} as any) +const ProjectShareTokenIndexRoute = ProjectShareTokenIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ProjectShareTokenRoute, +} as any) +const ProjectShareTokenVideoIdRoute = ProjectShareTokenVideoIdRouteImport.update({ + id: '/$videoId', + path: '/$videoId', + getParentRoute: () => ProjectShareTokenRoute, +} as any) const InviteTokenRoute = InviteTokenRouteImport.update({ id: '/invite/$token', path: '/invite/$token', @@ -161,6 +179,7 @@ export interface FileRoutesByFullPath { '/for/video-editors': typeof ForVideoEditorsRoute '/invite/$token': typeof InviteTokenRoute '/share/$token': typeof ShareTokenRoute + '/project-share/$token': typeof ProjectShareTokenRouteWithChildren '/sign-in/$': typeof SignInSplatRoute '/sign-up/$': typeof SignUpSplatRoute '/watch/$publicId': typeof WatchPublicIdRoute @@ -170,6 +189,8 @@ export interface FileRoutesByFullPath { '/dashboard/$teamSlug/': typeof DashboardTeamSlugIndexRoute '/dashboard/$teamSlug/$projectId/$videoId': typeof DashboardTeamSlugProjectIdVideoIdRoute '/dashboard/$teamSlug/$projectId/': typeof DashboardTeamSlugProjectIdIndexRoute + '/project-share/$token/': typeof ProjectShareTokenIndexRoute + '/project-share/$token/$videoId': typeof ProjectShareTokenVideoIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -191,6 +212,8 @@ export interface FileRoutesByTo { '/dashboard/$teamSlug': typeof DashboardTeamSlugIndexRoute '/dashboard/$teamSlug/$projectId/$videoId': typeof DashboardTeamSlugProjectIdVideoIdRoute '/dashboard/$teamSlug/$projectId': typeof DashboardTeamSlugProjectIdIndexRoute + '/project-share/$token': typeof ProjectShareTokenIndexRoute + '/project-share/$token/$videoId': typeof ProjectShareTokenVideoIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -207,6 +230,7 @@ export interface FileRoutesById { '/for/video-editors': typeof ForVideoEditorsRoute '/invite/$token': typeof InviteTokenRoute '/share/$token': typeof ShareTokenRoute + '/project-share/$token': typeof ProjectShareTokenRouteWithChildren '/sign-in/$': typeof SignInSplatRoute '/sign-up/$': typeof SignUpSplatRoute '/watch/$publicId': typeof WatchPublicIdRoute @@ -216,6 +240,8 @@ export interface FileRoutesById { '/dashboard/$teamSlug/': typeof DashboardTeamSlugIndexRoute '/dashboard/$teamSlug/$projectId/$videoId': typeof DashboardTeamSlugProjectIdVideoIdRoute '/dashboard/$teamSlug/$projectId/': typeof DashboardTeamSlugProjectIdIndexRoute + '/project-share/$token/': typeof ProjectShareTokenIndexRoute + '/project-share/$token/$videoId': typeof ProjectShareTokenVideoIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -233,6 +259,7 @@ export interface FileRouteTypes { | '/for/video-editors' | '/invite/$token' | '/share/$token' + | '/project-share/$token' | '/sign-in/$' | '/sign-up/$' | '/watch/$publicId' @@ -242,6 +269,8 @@ export interface FileRouteTypes { | '/dashboard/$teamSlug/' | '/dashboard/$teamSlug/$projectId/$videoId' | '/dashboard/$teamSlug/$projectId/' + | '/project-share/$token/' + | '/project-share/$token/$videoId' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -263,6 +292,8 @@ export interface FileRouteTypes { | '/dashboard/$teamSlug' | '/dashboard/$teamSlug/$projectId/$videoId' | '/dashboard/$teamSlug/$projectId' + | '/project-share/$token' + | '/project-share/$token/$videoId' id: | '__root__' | '/' @@ -278,6 +309,7 @@ export interface FileRouteTypes { | '/for/video-editors' | '/invite/$token' | '/share/$token' + | '/project-share/$token' | '/sign-in/$' | '/sign-up/$' | '/watch/$publicId' @@ -287,6 +319,8 @@ export interface FileRouteTypes { | '/dashboard/$teamSlug/' | '/dashboard/$teamSlug/$projectId/$videoId' | '/dashboard/$teamSlug/$projectId/' + | '/project-share/$token/' + | '/project-share/$token/$videoId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -303,6 +337,7 @@ export interface RootRouteChildren { InviteTokenRoute: typeof InviteTokenRoute ShareTokenRoute: typeof ShareTokenRoute WatchPublicIdRoute: typeof WatchPublicIdRoute + ProjectShareTokenRoute: typeof ProjectShareTokenRouteWithChildren } declare module '@tanstack/react-router' { @@ -384,6 +419,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShareTokenRouteImport parentRoute: typeof rootRouteImport } + '/project-share/$token': { + id: '/project-share/$token' + path: '/project-share/$token' + fullPath: '/project-share/$token' + preLoaderRoute: typeof ProjectShareTokenRouteImport + parentRoute: typeof rootRouteImport + } + '/project-share/$token/': { + id: '/project-share/$token/' + path: '/' + fullPath: '/project-share/$token/' + preLoaderRoute: typeof ProjectShareTokenIndexRouteImport + parentRoute: typeof ProjectShareTokenRoute + } + '/project-share/$token/$videoId': { + id: '/project-share/$token/$videoId' + path: '/$videoId' + fullPath: '/project-share/$token/$videoId' + preLoaderRoute: typeof ProjectShareTokenVideoIdRouteImport + parentRoute: typeof ProjectShareTokenRoute + } '/invite/$token': { id: '/invite/$token' path: '/invite/$token' @@ -464,6 +520,19 @@ declare module '@tanstack/react-router' { } } +interface ProjectShareTokenRouteChildren { + ProjectShareTokenIndexRoute: typeof ProjectShareTokenIndexRoute + ProjectShareTokenVideoIdRoute: typeof ProjectShareTokenVideoIdRoute +} + +const ProjectShareTokenRouteChildren: ProjectShareTokenRouteChildren = { + ProjectShareTokenIndexRoute: ProjectShareTokenIndexRoute, + ProjectShareTokenVideoIdRoute: ProjectShareTokenVideoIdRoute, +} + +const ProjectShareTokenRouteWithChildren = + ProjectShareTokenRoute._addFileChildren(ProjectShareTokenRouteChildren) + interface DashboardTeamSlugProjectIdRouteChildren { DashboardTeamSlugProjectIdVideoIdRoute: typeof DashboardTeamSlugProjectIdVideoIdRoute DashboardTeamSlugProjectIdIndexRoute: typeof DashboardTeamSlugProjectIdIndexRoute @@ -546,6 +615,7 @@ const rootRouteChildren: RootRouteChildren = { InviteTokenRoute: InviteTokenRoute, ShareTokenRoute: ShareTokenRoute, WatchPublicIdRoute: WatchPublicIdRoute, + ProjectShareTokenRoute: ProjectShareTokenRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/app/routes/-project-share-video.tsx b/app/routes/-project-share-video.tsx new file mode 100644 index 0000000..1059cd2 --- /dev/null +++ b/app/routes/-project-share-video.tsx @@ -0,0 +1,237 @@ +import { useAction, useQuery } from "convex/react"; +import { api } from "@convex/_generated/api"; +import { Link, useParams } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { VideoPlayer, type VideoPlayerHandle } from "@/components/video-player/VideoPlayer"; +import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { formatDuration, formatTimestamp, formatRelativeTime } from "@/lib/utils"; +import { AlertCircle, ArrowLeft } from "lucide-react"; +export default function ProjectShareVideoPage() { + const params = useParams({ strict: false }); + const token = params.token as string; + const videoId = params.videoId as string; + + const getPlaybackSession = useAction(api.videoActions.getProjectSharePlaybackSession); + + const video = useQuery(api.videos.getForProjectShare, { shareToken: token, videoId }); + const comments = useQuery(api.comments.getThreadedForProjectShare, { shareToken: token, videoId }); + + const [playbackSession, setPlaybackSession] = useState<{ + url: string; + posterUrl: string; + } | null>(null); + const [isLoadingPlayback, setIsLoadingPlayback] = useState(false); + const [playbackError, setPlaybackError] = useState(null); + const [currentTime, setCurrentTime] = useState(0); + const playerRef = useRef(null); + + useEffect(() => { + if (!video?.muxPlaybackId) { + setPlaybackSession(null); + setIsLoadingPlayback(false); + setPlaybackError(null); + return; + } + + let cancelled = false; + setPlaybackSession(null); + setIsLoadingPlayback(true); + setPlaybackError(null); + + void getPlaybackSession({ shareToken: token, videoId }) + .then((session) => { + if (cancelled) return; + setPlaybackSession(session); + }) + .catch(() => { + if (cancelled) return; + setPlaybackError("Unable to load playback session."); + }) + .finally(() => { + if (cancelled) return; + setIsLoadingPlayback(false); + }); + + return () => { + cancelled = true; + }; + }, [getPlaybackSession, token, videoId, video?.muxPlaybackId]); + + const flattenedComments = useMemo(() => { + if (!comments) return [] as Array<{ _id: string; timestampSeconds: number; resolved: boolean }>; + + const markers: Array<{ _id: string; timestampSeconds: number; resolved: boolean }> = []; + for (const comment of comments) { + markers.push({ + _id: comment._id, + timestampSeconds: comment.timestampSeconds, + resolved: comment.resolved, + }); + for (const reply of comment.replies) { + markers.push({ + _id: reply._id, + timestampSeconds: reply.timestampSeconds, + resolved: reply.resolved, + }); + } + } + return markers; + }, [comments]); + + if (video === undefined) { + return ( +
+
Loading...
+
+ ); + } + + if (video === null) { + return ( +
+ + +
+ +
+ Video not available + + This video is not available or the share link is invalid. + +
+
+
+ ); + } + + return ( +
+
+
+ + + Back to project + + + lawn + +
+
+ +
+
+

{video.title}

+ {video.description && ( +

{video.description}

+ )} +
+ {video.duration && {formatDuration(video.duration)}} + {comments && {comments.length} threads} +
+
+ +
+ {playbackSession?.url ? ( + + ) : ( +
+ {video.thumbnailUrl?.startsWith("http") ? ( + {`${video.title} + ) : null} +
+
+ {!playbackError && isLoadingPlayback && ( +
+ )} +

+ {playbackError ?? (isLoadingPlayback ? "Loading stream..." : "Preparing stream...")} +

+
+
+ )} +
+ +
+
+

Comments

+ {formatTimestamp(currentTime)} +
+ + {comments === undefined ? ( +

Loading comments...

+ ) : comments.length === 0 ? ( +

No comments yet.

+ ) : ( +
+ {comments.map((comment) => ( +
+
+
{comment.userName}
+ +
+

{comment.text}

+

{formatRelativeTime(comment._creationTime)}

+ + {comment.replies.length > 0 ? ( +
+ {comment.replies.map((reply) => ( +
+
+ {reply.userName} + +
+

{reply.text}

+
+ ))} +
+ ) : null} +
+ ))} +
+ )} +
+
+ +
+
+ Shared via{" "} + + lawn + +
+
+
+ ); +} diff --git a/app/routes/-project-share.tsx b/app/routes/-project-share.tsx new file mode 100644 index 0000000..bd91150 --- /dev/null +++ b/app/routes/-project-share.tsx @@ -0,0 +1,124 @@ +import { useQuery } from "convex/react"; +import { api } from "@convex/_generated/api"; +import { Link, useParams } from "@tanstack/react-router"; +import { Play } from "lucide-react"; +import { formatDuration } from "@/lib/utils"; +import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { AlertCircle } from "lucide-react"; + +export default function ProjectSharePage() { + const params = useParams({ strict: false }); + const token = params.token as string; + + const project = useQuery(api.projects.getByShareToken, { shareToken: token }); + const videos = useQuery(api.videos.listForProjectShare, { shareToken: token }); + + if (project === undefined) { + return ( +
+
Loading...
+
+ ); + } + + if (project === null) { + return ( +
+ + +
+ +
+ Project not found + + This share link is invalid or has been revoked. + +
+
+
+ ); + } + + return ( +
+
+
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+ + lawn + +
+
+ +
+ {videos === undefined ? ( +
Loading videos...
+ ) : videos.length === 0 ? ( +
+ No videos yet. +
+ ) : ( +
+ {videos.map((video) => { + const thumbnailSrc = video.thumbnailUrl?.startsWith("http") + ? video.thumbnailUrl + : undefined; + + return ( + +
+ {thumbnailSrc ? ( + {video.title} + ) : ( +
+ +
+ )} + {video.duration && ( +
+ {formatDuration(video.duration)} +
+ )} +
+
+

+ {video.title} +

+
+ + ); + })} +
+ )} +
+ +
+
+ Shared via{" "} + + lawn + +
+
+
+ ); +} diff --git a/app/routes/dashboard/-project.tsx b/app/routes/dashboard/-project.tsx index 6244016..c9e8639 100644 --- a/app/routes/dashboard/-project.tsx +++ b/app/routes/dashboard/-project.tsx @@ -19,6 +19,7 @@ import { Download, MessageSquare, Eye, + Share, } from "lucide-react"; import { DropdownMenu, @@ -28,7 +29,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Id } from "@convex/_generated/dataModel"; import { cn } from "@/lib/utils"; -import { teamHomePath, videoPath } from "@/lib/routes"; +import { teamHomePath, videoPath, projectSharePath } from "@/lib/routes"; import { prefetchHlsRuntime, prefetchMuxPlaybackManifest } from "@/lib/muxPlayback"; import { useRoutePrewarmIntent } from "@/lib/useRoutePrewarmIntent"; import { @@ -142,9 +143,24 @@ export default function ProjectPage({ const updateVideoWorkflowStatus = useMutation(api.videos.updateWorkflowStatus); const getDownloadUrl = useAction(api.videoActions.getDownloadUrl); + const generateProjectShare = useMutation(api.projects.generateShareToken); + const revokeProjectShare = useMutation(api.projects.revokeShareToken); + const shareTokenData = useQuery( + api.projects.getShareToken, + resolvedProjectId && project?.role !== "viewer" ? { projectId: resolvedProjectId } : "skip", + ); + const isShareTokenLoading = + resolvedProjectId !== undefined && + project?.role !== "viewer" && + shareTokenData === undefined; + const shareToken = shareTokenData?.shareToken ?? null; + const [viewMode, setViewMode] = useState("grid"); const [shareToast, setShareToast] = useState(null); const shareToastTimeoutRef = useRef(null); + const [showSharePanel, setShowSharePanel] = useState(false); + const [isGeneratingToken, setIsGeneratingToken] = useState(false); + const sharePanelRef = useRef(null); const shouldCanonicalize = !!context && !context.isCanonical && pathname !== context.canonicalPath; @@ -167,6 +183,17 @@ export default function ProjectPage({ [], ); + useEffect(() => { + if (!showSharePanel) return; + const handleClickOutside = (event: MouseEvent) => { + if (sharePanelRef.current && !sharePanelRef.current.contains(event.target as Node)) { + setShowSharePanel(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showSharePanel]); + const isLoadingData = context === undefined || project === undefined || @@ -226,6 +253,24 @@ export default function ProjectPage({ }, 2400); }, []); + const handleShareProject = useCallback(async () => { + if (!resolvedProjectId || isShareTokenLoading) return; + if (shareToken) { + setShowSharePanel(true); + return; + } + setIsGeneratingToken(true); + try { + await generateProjectShare({ projectId: resolvedProjectId }); + setShowSharePanel(true); + } catch (error) { + console.error("Failed to generate share token:", error); + showShareToast("error", "Could not generate share link"); + } finally { + setIsGeneratingToken(false); + } + }, [shareToken, isShareTokenLoading, resolvedProjectId, generateProjectShare, showShareToast]); + const handleShareVideo = useCallback( async (video: { _id: Id<"videos">; @@ -313,6 +358,66 @@ export default function ProjectPage({ + {canUpload && ( +
+ + {showSharePanel && shareToken && ( +
+

Project share link

+
+ + +
+ +
+ )} +
+ )} {canUpload && ( )} diff --git a/app/routes/project-share.$token.$videoId.tsx b/app/routes/project-share.$token.$videoId.tsx new file mode 100644 index 0000000..b65ab26 --- /dev/null +++ b/app/routes/project-share.$token.$videoId.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { seoHead } from "@/lib/seo"; +import ProjectShareVideoPage from "./-project-share-video"; + +export const Route = createFileRoute("/project-share/$token/$videoId")({ + head: ({ params }) => + seoHead({ + title: "Shared video", + description: "Watch this shared video on lawn.", + path: `/project-share/${params.token}/${params.videoId}`, + noIndex: true, + }), + component: ProjectShareVideoPage, +}); diff --git a/app/routes/project-share.$token.index.tsx b/app/routes/project-share.$token.index.tsx new file mode 100644 index 0000000..8425593 --- /dev/null +++ b/app/routes/project-share.$token.index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { seoHead } from "@/lib/seo"; +import ProjectSharePage from "./-project-share"; + +export const Route = createFileRoute("/project-share/$token/")({ + head: ({ params }) => + seoHead({ + title: "Shared project", + description: "Browse this shared project on lawn.", + path: `/project-share/${params.token}`, + noIndex: true, + }), + component: ProjectSharePage, +}); diff --git a/app/routes/project-share.$token.tsx b/app/routes/project-share.$token.tsx new file mode 100644 index 0000000..124c6b6 --- /dev/null +++ b/app/routes/project-share.$token.tsx @@ -0,0 +1,9 @@ +import { Outlet, createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/project-share/$token")({ + component: ProjectShareLayout, +}); + +function ProjectShareLayout() { + return ; +} diff --git a/convex/comments.ts b/convex/comments.ts index 772fa97..4967ea0 100644 --- a/convex/comments.ts +++ b/convex/comments.ts @@ -267,6 +267,33 @@ export const getThreadedForPublic = query({ }, }); +export const getThreadedForProjectShare = query({ + args: { shareToken: v.string(), videoId: v.string() }, + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_share_token", (q) => q.eq("shareToken", args.shareToken)) + .unique(); + + if (!project) return []; + + const normalizedVideoId = ctx.db.normalizeId("videos", args.videoId); + if (!normalizedVideoId) return []; + + const video = await ctx.db.get(normalizedVideoId); + if (!video || video.projectId !== project._id || video.status !== "ready") { + return []; + } + + const comments = await ctx.db + .query("comments") + .withIndex("by_video", (q) => q.eq("videoId", video._id)) + .collect(); + + return toThreadedComments(comments.map(toPublicCommentPayload)); + }, +}); + export const getThreadedForShareGrant = query({ args: { grantToken: v.string() }, handler: async (ctx, args) => { diff --git a/convex/projects.ts b/convex/projects.ts index 2f10e79..e8ba8a1 100644 --- a/convex/projects.ts +++ b/convex/projects.ts @@ -2,6 +2,15 @@ import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; import { getUser, requireTeamAccess, requireProjectAccess } from "./auth"; import { assertTeamHasActiveSubscription } from "./billingHelpers"; +import { generateUniqueToken } from "./security"; + +function omitShareToken( + project: T, +): Omit { + const copy = { ...project } as Record; + delete copy.shareToken; + return copy as Omit; +} export const create = mutation({ args: { @@ -38,8 +47,9 @@ export const list = query({ .query("videos") .withIndex("by_project", (q) => q.eq("projectId", project._id)) .collect(); + const projectWithoutToken = omitShareToken(project); return { - ...project, + ...projectWithoutToken, videoCount: videos.length, }; }) @@ -101,7 +111,16 @@ export const get = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { const { project, membership } = await requireProjectAccess(ctx, args.projectId); - return { ...project, role: membership.role }; + const projectWithoutToken = omitShareToken(project); + return { ...projectWithoutToken, role: membership.role }; + }, +}); + +export const getShareToken = query({ + args: { projectId: v.id("projects") }, + handler: async (ctx, args) => { + const { project } = await requireProjectAccess(ctx, args.projectId, "member"); + return { shareToken: project.shareToken ?? null }; }, }); @@ -122,6 +141,52 @@ export const update = mutation({ }, }); +export const generateShareToken = mutation({ + args: { projectId: v.id("projects") }, + handler: async (ctx, args) => { + await requireProjectAccess(ctx, args.projectId, "member"); + + const token = await generateUniqueToken( + 32, + async (candidate) => + (await ctx.db + .query("projects") + .withIndex("by_share_token", (q) => q.eq("shareToken", candidate)) + .unique()) !== null, + 5, + ); + + await ctx.db.patch(args.projectId, { shareToken: token }); + return { token }; + }, +}); + +export const revokeShareToken = mutation({ + args: { projectId: v.id("projects") }, + handler: async (ctx, args) => { + await requireProjectAccess(ctx, args.projectId, "member"); + await ctx.db.patch(args.projectId, { shareToken: undefined }); + }, +}); + +export const getByShareToken = query({ + args: { shareToken: v.string() }, + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_share_token", (q) => q.eq("shareToken", args.shareToken)) + .unique(); + + if (!project) return null; + + return { + _id: project._id, + name: project.name, + description: project.description, + }; + }, +}); + export const remove = mutation({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { diff --git a/convex/schema.ts b/convex/schema.ts index 4b2de9e..6f15871 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -61,7 +61,10 @@ export default defineSchema({ teamId: v.id("teams"), name: v.string(), description: v.optional(v.string()), - }).index("by_team", ["teamId"]), + shareToken: v.optional(v.string()), + }) + .index("by_team", ["teamId"]) + .index("by_share_token", ["shareToken"]), videos: defineTable({ projectId: v.id("projects"), diff --git a/convex/videoActions.ts b/convex/videoActions.ts index 43fde9d..32bfd60 100644 --- a/convex/videoActions.ts +++ b/convex/videoActions.ts @@ -487,6 +487,34 @@ export const getSharedPlaybackSession = action({ }, }); +export const getProjectSharePlaybackSession = action({ + args: { shareToken: v.string(), videoId: v.string() }, + returns: v.object({ + url: v.string(), + posterUrl: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ url: string; posterUrl: string }> => { + const result = await ctx.runQuery(api.videos.getForProjectShare, { + shareToken: args.shareToken, + videoId: args.videoId, + }); + + if (!result?.muxPlaybackId) { + throw new Error("Video not found or not ready"); + } + + const playbackId = await ensurePublicPlaybackId(ctx, { + videoId: result._id, + muxAssetId: result.muxAssetId, + muxPlaybackId: result.muxPlaybackId, + }); + return buildPublicPlaybackSession(playbackId); + }, +}); + export const getDownloadUrl = action({ args: { videoId: v.id("videos") }, returns: v.object({ diff --git a/convex/videos.ts b/convex/videos.ts index dae50eb..c2b2c24 100644 --- a/convex/videos.ts +++ b/convex/videos.ts @@ -471,6 +471,66 @@ export const incrementViewCount = mutation({ }, }); +export const listForProjectShare = query({ + args: { shareToken: v.string() }, + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_share_token", (q) => q.eq("shareToken", args.shareToken)) + .unique(); + + if (!project) return []; + + const videos = await ctx.db + .query("videos") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .order("desc") + .collect(); + + return videos + .filter((v) => v.status === "ready") + .map((video) => ({ + _id: video._id, + title: video.title, + duration: video.duration, + thumbnailUrl: video.thumbnailUrl, + muxPlaybackId: video.muxPlaybackId, + workflowStatus: normalizeWorkflowStatus(video.workflowStatus), + })); + }, +}); + +export const getForProjectShare = query({ + args: { shareToken: v.string(), videoId: v.string() }, + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_share_token", (q) => q.eq("shareToken", args.shareToken)) + .unique(); + + if (!project) return null; + + const normalizedVideoId = ctx.db.normalizeId("videos", args.videoId); + if (!normalizedVideoId) return null; + + const video = await ctx.db.get(normalizedVideoId); + if (!video || video.projectId !== project._id || video.status !== "ready") { + return null; + } + + return { + _id: video._id, + title: video.title, + description: video.description, + duration: video.duration, + thumbnailUrl: video.thumbnailUrl, + muxPlaybackId: video.muxPlaybackId, + muxAssetId: video.muxAssetId, + workflowStatus: normalizeWorkflowStatus(video.workflowStatus), + }; + }, +}); + export const updateDuration = mutation({ args: { videoId: v.id("videos"), diff --git a/src/lib/routes.ts b/src/lib/routes.ts index b451a47..96e5745 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -17,3 +17,11 @@ export function projectPath(teamSlug: string, projectId: string) { export function videoPath(teamSlug: string, projectId: string, videoId: string) { return `/dashboard/${teamSlug}/${projectId}/${videoId}`; } + +export function projectSharePath(token: string) { + return `/project-share/${token}`; +} + +export function projectShareVideoPath(token: string, videoId: string) { + return `/project-share/${token}/${videoId}`; +}