Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 90 additions & 30 deletions app/routes/dashboard/-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useAuth } from "@clerk/tanstack-react-start";
import { useConvex, useQuery } from "convex/react";
import { useConvex, useConvexAuth, useQuery } from "convex/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@convex/_generated/api";
import type { Id } from "@convex/_generated/dataModel";
Expand All @@ -16,8 +16,9 @@ import {
import { UploadProgress } from "@/components/upload/UploadProgress";
import { useVideoUploadManager } from "./-useVideoUploadManager";
import { DashboardUploadProvider } from "@/lib/dashboardUploadContext";
import { videoPath } from "@/lib/routes";
import { videoPath, watchPath } from "@/lib/routes";
import { prewarmVideo } from "./-video.data";
import { resolveDashboardAccess } from "@/lib/dashboardAccess";

const VIDEO_FILE_EXTENSIONS = /\.(mp4|mov|m4v|webm|avi|mkv)$/i;

Expand All @@ -31,25 +32,44 @@ function dragEventHasFiles(event: DragEvent) {

export default function DashboardLayout() {
const { isLoaded, userId } = useAuth();
const { isLoading: isConvexAuthLoading, isAuthenticated: isConvexAuthenticated } =
useConvexAuth();
const convex = useConvex();
const navigate = useNavigate({});
const location = useLocation();
const { pathname, searchStr } = location;
const params = useParams({ strict: false });
const teamSlug = typeof params.teamSlug === "string" ? params.teamSlug : undefined;
const routeProjectId =
typeof params.projectId === "string" ? (params.projectId as Id<"projects">) : undefined;
const routeVideoId =
typeof params.videoId === "string" ? (params.videoId as Id<"videos">) : undefined;
const rawProjectId = typeof params.projectId === "string" ? params.projectId : undefined;
const rawVideoId = typeof params.videoId === "string" ? params.videoId : undefined;
const publicPlaybackId = useQuery(
api.videos.getPublicIdByVideoId,
routeVideoId ? { videoId: routeVideoId } : "skip",
rawVideoId ? { videoId: rawVideoId } : "skip",
);
const detailVideo = useQuery(
api.videos.get,
routeVideoId && userId ? { videoId: routeVideoId } : "skip",
const contextRequired = Boolean(teamSlug || rawProjectId || rawVideoId);
const workspaceContext = useQuery(
api.workspace.resolveContext,
isLoaded && Boolean(userId) && !isConvexAuthLoading && isConvexAuthenticated && contextRequired
? { teamSlug, projectId: rawProjectId, videoId: rawVideoId }
: "skip",
);
const access = resolveDashboardAccess({
clerkLoaded: isLoaded,
hasClerkUser: Boolean(userId),
convexAuthLoading: isConvexAuthLoading,
convexAuthenticated: isConvexAuthenticated,
contextRequired,
workspaceContext,
publicLookupRequired: Boolean(rawVideoId),
publicId: publicPlaybackId,
});
const routeProjectId = access.kind === "dashboard" ? workspaceContext?.project?._id : undefined;
const routeVideoId = access.kind === "dashboard" ? workspaceContext?.video?._id : undefined;
const detailVideo = useQuery(api.videos.get, routeVideoId ? { videoId: routeVideoId } : "skip");
const uploadTargets = useQuery(
api.projects.listUploadTargets,
access.kind === "dashboard" ? (teamSlug ? { teamSlug } : {}) : "skip",
);
const uploadTargets = useQuery(api.projects.listUploadTargets, teamSlug ? { teamSlug } : {});
const { uploads, uploadFilesToProject, uploadNewVersion, cancelUpload, retryProcessing } =
useVideoUploadManager();
const [isGlobalDragActive, setIsGlobalDragActive] = useState(false);
Expand Down Expand Up @@ -217,40 +237,80 @@ export default function DashboardLayout() {
}),
[requestUpload, requestVersionUpload, uploads, cancelUpload, retryProcessing],
);
const isResolvingPublicPlaybackExemption =
Boolean(isLoaded && !userId && routeVideoId) && publicPlaybackId === undefined;
const publicRedirectId = access.kind === "redirect-public" ? access.publicId : undefined;
const shouldRedirectToSignIn = access.kind === "redirect-sign-in";

useEffect(() => {
if (!isLoaded || userId) return;
if (typeof window === "undefined") return;

if (routeVideoId) {
if (publicPlaybackId === undefined) return;
if (publicPlaybackId) {
window.location.replace(`/watch/${publicPlaybackId}`);
return;
}
if (publicRedirectId) {
window.location.replace(watchPath(publicRedirectId));
return;
}

const redirectUrl = `${pathname}${searchStr}`;
window.location.replace(`/sign-in?redirect_url=${encodeURIComponent(redirectUrl)}`);
}, [isLoaded, userId, pathname, searchStr, routeVideoId, publicPlaybackId]);
if (shouldRedirectToSignIn) {
const redirectUrl = `${pathname}${searchStr}`;
window.location.replace(`/sign-in?redirect_url=${encodeURIComponent(redirectUrl)}`);
}
}, [pathname, publicRedirectId, searchStr, shouldRedirectToSignIn]);

if (!isLoaded) {
if (access.kind === "loading") {
return (
<div className="flex h-full items-center justify-center bg-[#f0f0e8]">
<div className="text-[#888]">Loading...</div>
<div role="status" aria-live="polite" className="text-[#888]">
Checking access...
</div>
</div>
);
}

if (!userId) {
if (access.kind === "redirect-public" || access.kind === "redirect-sign-in") {
return (
<div className="flex h-full items-center justify-center bg-[#f0f0e8]">
<div className="text-[#888]">
{isResolvingPublicPlaybackExemption
? "Checking public playback access..."
: "Redirecting to sign in..."}
<div role="status" aria-live="polite" className="text-[#888]">
Redirecting...
</div>
</div>
);
}

if (access.kind === "auth-unavailable") {
return (
<div className="flex h-full items-center justify-center bg-[#f0f0e8] p-6">
<div
role="alert"
className="max-w-md border-2 border-[#1a1a1a] bg-[#f0f0e8] p-5 text-center shadow-[4px_4px_0px_0px_var(--shadow-color)]"
>
<p className="font-bold text-[#1a1a1a]">We couldn't verify your dashboard session.</p>
<p className="mt-1 text-sm text-[#888]">Try again, or return home and sign in again.</p>
<div className="mt-4 flex justify-center gap-2">
<button
type="button"
className="border-2 border-[#1a1a1a] bg-[#1a1a1a] px-3 py-2 text-sm font-bold text-[#f0f0e8]"
onClick={() => window.location.reload()}
>
Try again
</button>
<a
href="/"
className="border-2 border-[#1a1a1a] px-3 py-2 text-sm font-bold text-[#1a1a1a]"
>
Go home
</a>
</div>
</div>
</div>
);
}

if (access.kind === "not-found") {
return (
<div className="flex h-full items-center justify-center bg-[#f0f0e8] p-6">
<div role="alert" className="text-center text-[#888]">
<p>Video or workspace not found</p>
<a className="mt-3 inline-block font-bold text-[#1a1a1a] underline" href="/dashboard">
Back to dashboard
</a>
</div>
</div>
);
Expand Down
115 changes: 60 additions & 55 deletions app/routes/dashboard/-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Id } from "@convex/_generated/dataModel";
import { cn } from "@/lib/utils";
import { projectPath, teamHomePath, videoPath } from "@/lib/routes";
import { projectPath, teamHomePath, videoPath, watchPath } from "@/lib/routes";
import { copyTextToClipboard } from "@/lib/clipboard";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { MoveProjectDialog } from "@/components/projects/MoveProjectDialog";
import { MoveVideoDialog } from "@/components/videos/MoveVideoDialog";
Expand All @@ -56,42 +57,15 @@ import { prewarmTeam } from "./-team.data";
import { prewarmVideo } from "./-video.data";
import { useDashboardUploadContext } from "@/lib/dashboardUploadContext";
import { DashboardHeader } from "@/components/DashboardHeader";
import { createRequestEpoch } from "@/lib/requestEpoch";

type ViewMode = "grid" | "list";
type ShareToastState = {
tone: "success" | "error";
message: string;
url?: string;
};

async function copyTextToClipboard(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}

if (typeof document === "undefined") {
return false;
}

const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();

let copied = false;
try {
copied = document.execCommand("copy");
} finally {
document.body.removeChild(textarea);
}

return copied;
}

type VideoIntentTargetProps = {
className: string;
teamSlug: string;
Expand Down Expand Up @@ -193,6 +167,9 @@ export default function ProjectPage({
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [shareToast, setShareToast] = useState<ShareToastState | null>(null);
const shareToastTimeoutRef = useRef<number | null>(null);
const shareRequestEpochRef = useRef(createRequestEpoch());
const sharePendingRef = useRef(false);
const [sharePending, setSharePending] = useState(false);
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
Expand Down Expand Up @@ -235,6 +212,7 @@ export default function ProjectPage({

useEffect(
() => () => {
shareRequestEpochRef.current.invalidate();
if (shareToastTimeoutRef.current !== null) {
window.clearTimeout(shareToastTimeoutRef.current);
}
Expand Down Expand Up @@ -332,46 +310,54 @@ export default function ProjectPage({
[updateVideoWorkflowStatus],
);

const showShareToast = useCallback((tone: ShareToastState["tone"], message: string) => {
setShareToast({ tone, message });
const showShareToast = useCallback((toast: ShareToastState, autoDismiss: boolean) => {
setShareToast(toast);
if (shareToastTimeoutRef.current !== null) {
window.clearTimeout(shareToastTimeoutRef.current);
}
shareToastTimeoutRef.current = window.setTimeout(() => {
setShareToast(null);
shareToastTimeoutRef.current = null;
}, 2400);
}
if (autoDismiss) {
shareToastTimeoutRef.current = window.setTimeout(() => {
setShareToast(null);
shareToastTimeoutRef.current = null;
}, 2400);
}
}, []);

const handleShareVideo = useCallback(
async (video: {
_id: Id<"videos">;
publicId?: string;
status: string;
visibility: "public" | "private";
}) => {
const canSharePublicly =
Boolean(video.publicId) && video.status === "ready" && video.visibility === "public";
const path = canSharePublicly
? `/watch/${video.publicId}`
: videoPath(resolvedTeamSlug, projectId, video._id);
async (video: { _id: Id<"videos">; publicId?: string; visibility: "public" | "private" }) => {
if (sharePendingRef.current) return;
sharePendingRef.current = true;
setSharePending(true);
setShareToast(null);
const requestEpoch = shareRequestEpochRef.current.next();
const publicWatchPath =
video.visibility === "public" && video.publicId ? watchPath(video.publicId) : null;
const path = publicWatchPath ?? videoPath(resolvedTeamSlug, projectId, video._id);
const origin = typeof window !== "undefined" ? window.location.origin : "";
const url = `${origin}${path}`;

try {
const copied = await copyTextToClipboard(url);
if (!shareRequestEpochRef.current.isCurrent(requestEpoch)) return;
sharePendingRef.current = false;
setSharePending(false);
if (!copied) {
showShareToast("error", "Could not copy link");
showShareToast({ tone: "error", message: "Could not copy link", url }, false);
return;
}
showShareToast(
"success",
canSharePublicly
? "Share link copied"
: "Video link copied (public watch link not available yet)",
{
tone: "success",
message: publicWatchPath ? "Share link copied" : "Private dashboard link copied",
},
true,
);
} catch {
showShareToast("error", "Could not copy link");
if (!shareRequestEpochRef.current.isCurrent(requestEpoch)) return;
sharePendingRef.current = false;
setSharePending(false);
showShareToast({ tone: "error", message: "Could not copy link", url }, false);
}
},
[projectId, resolvedTeamSlug, showShareToast],
Expand Down Expand Up @@ -635,6 +621,7 @@ export default function ProjectPage({
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={sharePending}
onClick={(e) => {
e.stopPropagation();
void handleShareVideo(video);
Expand Down Expand Up @@ -854,6 +841,7 @@ export default function ProjectPage({
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={sharePending}
onClick={(e) => {
e.stopPropagation();
void handleShareVideo(video);
Expand Down Expand Up @@ -912,16 +900,33 @@ export default function ProjectPage({
</div>

{shareToast ? (
<div className="fixed top-4 right-4 z-50" aria-live="polite">
<div className="fixed top-4 right-4 z-50 w-[min(28rem,calc(100vw-2rem))]">
<div
role={shareToast.tone === "error" ? "alert" : "status"}
aria-live={shareToast.tone === "error" ? "assertive" : "polite"}
className={cn(
"border-2 px-3 py-2 text-sm font-bold shadow-[4px_4px_0px_0px_var(--shadow-color)]",
shareToast.tone === "success"
? "border-[#1a1a1a] bg-[#f0f0e8] text-[#1a1a1a]"
: "border-[#dc2626] bg-[#fef2f2] text-[#dc2626]",
)}
>
{shareToast.message}
<p>{shareToast.message}</p>
{shareToast.url ? (
<div className="mt-2 space-y-2">
<Input
aria-label="Link to copy manually"
className="bg-white font-mono text-xs font-normal"
readOnly
value={shareToast.url}
onFocus={(event) => event.currentTarget.select()}
onClick={(event) => event.currentTarget.select()}
/>
<Button size="sm" variant="outline" onClick={() => setShareToast(null)}>
Dismiss
</Button>
</div>
) : null}
</div>
</div>
) : null}
Expand Down
Loading