From 5d1a48173a630e902a3653db72033297b7a8c008 Mon Sep 17 00:00:00 2001 From: Malthe Engmann Kristensen Date: Mon, 9 Mar 2026 15:32:01 +0100 Subject: [PATCH 1/5] feat: add project-level share links Adds a single shareable URL per project so teams can share all videos at once without sending individual links for every video. - convex/schema.ts: add shareToken field + by_share_token index to projects - convex/projects.ts: add generateShareToken, revokeShareToken, getByShareToken - convex/videos.ts: add listForProjectShare, getForProjectShare (public queries) - convex/comments.ts: add getThreadedForProjectShare (public query) - convex/videoActions.ts: add getProjectSharePlaybackSession action - src/lib/routes.ts: add projectSharePath, projectShareVideoPath helpers - app/routes/project-share.$token.*: new public route tree - app/routes/-project-share.tsx: read-only video grid component - app/routes/-project-share-video.tsx: read-only video player + comments - app/routes/dashboard/-project.tsx: add Share button with popover UI Co-Authored-By: Claude Sonnet 4.6 --- app/routeTree.gen.ts | 70 ++++++ app/routes/-project-share-video.tsx | 234 +++++++++++++++++++ app/routes/-project-share.tsx | 124 ++++++++++ app/routes/dashboard/-project.tsx | 87 ++++++- app/routes/project-share.$token.$videoId.tsx | 14 ++ app/routes/project-share.$token.index.tsx | 14 ++ app/routes/project-share.$token.tsx | 9 + convex/comments.ts | 24 ++ convex/projects.ts | 47 ++++ convex/schema.ts | 5 +- convex/videoActions.ts | 28 +++ convex/videos.ts | 57 +++++ src/lib/routes.ts | 8 + 13 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 app/routes/-project-share-video.tsx create mode 100644 app/routes/-project-share.tsx create mode 100644 app/routes/project-share.$token.$videoId.tsx create mode 100644 app/routes/project-share.$token.index.tsx create mode 100644 app/routes/project-share.$token.tsx 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..fba565f --- /dev/null +++ b/app/routes/-project-share-video.tsx @@ -0,0 +1,234 @@ +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"; +import { Id } from "@convex/_generated/dataModel"; + +export default function ProjectShareVideoPage() { + const params = useParams({ strict: false }); + const token = params.token as string; + const videoId = params.videoId as Id<"videos">; + + 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); + return; + } + + let cancelled = false; + 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 ? "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..e49af12 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,15 @@ 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 [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 +174,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 || @@ -215,6 +233,23 @@ export default function ProjectPage({ [updateVideoWorkflowStatus], ); + const handleShareProject = useCallback(async () => { + if (!resolvedProjectId) return; + if (project?.shareToken) { + setShowSharePanel(true); + return; + } + setIsGeneratingToken(true); + try { + await generateProjectShare({ projectId: resolvedProjectId }); + setShowSharePanel(true); + } catch (error) { + console.error("Failed to generate share token:", error); + } finally { + setIsGeneratingToken(false); + } + }, [project?.shareToken, resolvedProjectId, generateProjectShare]); + const showShareToast = useCallback((tone: ShareToastState["tone"], message: string) => { setShareToast({ tone, message }); if (shareToastTimeoutRef.current !== null) { @@ -313,6 +348,56 @@ export default function ProjectPage({ + {canUpload && ( +
+ + {showSharePanel && project?.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..17b4374 --- /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: () => + seoHead({ + title: "Shared video", + description: "Watch this shared video on lawn.", + path: "/project-share", + 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..c4be4bc --- /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: () => + seoHead({ + title: "Shared project", + description: "Browse this shared project on lawn.", + path: "/project-share", + 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..91e5bb6 100644 --- a/convex/comments.ts +++ b/convex/comments.ts @@ -267,6 +267,30 @@ export const getThreadedForPublic = query({ }, }); +export const getThreadedForProjectShare = query({ + args: { shareToken: v.string(), videoId: v.id("videos") }, + 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 video = await ctx.db.get(args.videoId); + 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..d3129f5 100644 --- a/convex/projects.ts +++ b/convex/projects.ts @@ -2,6 +2,7 @@ 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"; export const create = mutation({ args: { @@ -122,6 +123,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..ca7991a 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.id("videos") }, + 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: args.videoId, + 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..c36cdc3 100644 --- a/convex/videos.ts +++ b/convex/videos.ts @@ -471,6 +471,63 @@ 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.id("videos") }, + 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 video = await ctx.db.get(args.videoId); + 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}`; +} From eddb485b3c2f3f6e7f6c619fe36be26da6e9c9f5 Mon Sep 17 00:00:00 2001 From: Malthe Engmann Kristensen Date: Mon, 9 Mar 2026 15:48:16 +0100 Subject: [PATCH 2/5] fix: address CodeRabbit and macroscope review issues - Fix hardcoded SEO paths in project-share routes (macroscope): use dynamic params.token/videoId in seoHead path so og:url and canonical tags point to the correct URL - Redact shareToken from projects.list/get queries to prevent leaking bearer tokens to viewer-role users; add member-only getShareToken query and update dashboard to use it - Add try/catch with user-facing toast feedback to revoke button and generate-token error path in project dashboard - Change videoId args from v.id("videos") to v.string() + normalizeId on all public project-share endpoints (getForProjectShare, getThreadedForProjectShare, getProjectSharePlaybackSession) so malformed URL segments return null instead of a validation error Co-Authored-By: Claude Sonnet 4.6 --- app/routes/-project-share-video.tsx | 4 +-- app/routes/dashboard/-project.tsx | 27 +++++++++++++++----- app/routes/project-share.$token.$videoId.tsx | 4 +-- app/routes/project-share.$token.index.tsx | 4 +-- convex/comments.ts | 7 +++-- convex/projects.ts | 14 ++++++++-- convex/videoActions.ts | 4 +-- convex/videos.ts | 7 +++-- 8 files changed, 49 insertions(+), 22 deletions(-) diff --git a/app/routes/-project-share-video.tsx b/app/routes/-project-share-video.tsx index fba565f..71f2640 100644 --- a/app/routes/-project-share-video.tsx +++ b/app/routes/-project-share-video.tsx @@ -6,12 +6,10 @@ import { VideoPlayer, type VideoPlayerHandle } from "@/components/video-player/V import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { formatDuration, formatTimestamp, formatRelativeTime } from "@/lib/utils"; import { AlertCircle, ArrowLeft } from "lucide-react"; -import { Id } from "@convex/_generated/dataModel"; - export default function ProjectShareVideoPage() { const params = useParams({ strict: false }); const token = params.token as string; - const videoId = params.videoId as Id<"videos">; + const videoId = params.videoId as string; const getPlaybackSession = useAction(api.videoActions.getProjectSharePlaybackSession); diff --git a/app/routes/dashboard/-project.tsx b/app/routes/dashboard/-project.tsx index e49af12..af2ec63 100644 --- a/app/routes/dashboard/-project.tsx +++ b/app/routes/dashboard/-project.tsx @@ -145,6 +145,11 @@ export default function ProjectPage({ 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 shareToken = shareTokenData?.shareToken ?? null; const [viewMode, setViewMode] = useState("grid"); const [shareToast, setShareToast] = useState(null); @@ -235,7 +240,7 @@ export default function ProjectPage({ const handleShareProject = useCallback(async () => { if (!resolvedProjectId) return; - if (project?.shareToken) { + if (shareToken) { setShowSharePanel(true); return; } @@ -245,10 +250,12 @@ export default function ProjectPage({ setShowSharePanel(true); } catch (error) { console.error("Failed to generate share token:", error); + showShareToast("error", "Could not generate share link"); } finally { setIsGeneratingToken(false); } - }, [project?.shareToken, resolvedProjectId, generateProjectShare]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shareToken, resolvedProjectId, generateProjectShare]); const showShareToast = useCallback((tone: ShareToastState["tone"], message: string) => { setShareToast({ tone, message }); @@ -359,21 +366,21 @@ export default function ProjectPage({ {isGeneratingToken ? "Sharing..." : "Share"} - {showSharePanel && project?.shareToken && ( + {showSharePanel && shareToken && (

Project share link