Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ DATABASE_URL='mysql://root:@localhost:3306/planetscale'
# Generate a value by running `openssl rand -hex 32`
DATABASE_ENCRYPTION_KEY=

# Video privacy settings
# Set to false to make new uploaded videos private by default. If not set or set to true, videos are public by default.
# CAP_VIDEOS_DEFAULT_PUBLIC=true

## AWS/S3

Expand Down
15 changes: 12 additions & 3 deletions apps/web/actions/caps/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import { revalidatePath } from 'next/cache'
import { db } from "@cap/database"
import { getCurrentUser } from "@cap/database/auth/session"
import { sharedVideos, videos, spaces, organizationMembers, organizations, spaceVideos } from "@cap/database/schema"
import { eq, and, inArray, or } from "drizzle-orm"
import { eq, and, inArray } from "drizzle-orm"
import { nanoId } from "@cap/database/helpers"

interface ShareCapParams {
capId: string
spaceIds: string[]
public?: boolean
}

export async function shareCap({ capId, spaceIds }: ShareCapParams) {
export async function shareCap({ capId, spaceIds, public: isPublic }: ShareCapParams) {
try {

const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
Expand Down Expand Up @@ -122,6 +122,15 @@ export async function shareCap({ capId, spaceIds }: ShareCapParams) {
})
}
}

// Update public status if provided
if (typeof isPublic === 'boolean') {
await db()
.update(videos)
.set({ public: isPublic })
.where(eq(videos.id, capId))
}

revalidatePath('/dashboard/caps')
revalidatePath(`/dashboard/caps/${capId}`)
return { success: true }
Expand Down
1 change: 1 addition & 0 deletions apps/web/actions/video/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export async function createVideoAndGetUploadUrl({
source: { type: "desktopMP4" as const },
isScreenshot,
bucket: customBucket?.id,
public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
...(folderId ? { folderId } : {}),
};

Expand Down
1 change: 1 addition & 0 deletions apps/web/app/(org)/dashboard/caps/Caps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type VideoData = {
ownerId: string;
name: string;
createdAt: Date;
public: boolean;
totalComments: number;
totalReactions: number;
foldersData: FolderDataType[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface CapCardProps extends PropsWithChildren {
ownerId: string;
name: string;
createdAt: Date;
public?: boolean;
totalComments: number;
totalReactions: number;
sharedOrganizations?: {
Expand Down Expand Up @@ -256,6 +257,7 @@ export const CapCard = ({
capName={cap.name}
sharedSpaces={cap.sharedSpaces || []}
onSharingUpdated={handleSharingUpdated}
isPublic={cap.public}
/>
<PasswordDialog
isOpen={isPasswordDialogOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ export const CapCardContent: React.FC<CapContentProps> = ({
hideSharedStatus ? "pointer-events-none" : "cursor-pointer"
);
if (isOwner && !hideSharedStatus) {
if (
(cap.sharedOrganizations?.length === 0 || !cap.sharedOrganizations) &&
(cap.sharedSpaces?.length === 0 || !cap.sharedSpaces)
) {
const hasSpaceSharing = (cap.sharedOrganizations?.length ?? 0) > 0 || (cap.sharedSpaces?.length ?? 0) > 0;
const isPublic = cap.public;

if (!hasSpaceSharing && !isPublic) {
return (
<p
className={baseClassName}
Expand Down
88 changes: 67 additions & 21 deletions apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import {
DialogTitle,
Input,
Avatar,
Switch,
} from "@cap/ui";
import { faShareNodes, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import { motion } from "framer-motion";
import { Check, Search } from "lucide-react";
import { Check, Search, Globe2 } from "lucide-react";
import Image from "next/image";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { shareCap } from "@/actions/caps/share";
import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import { Spaces } from "@/app/(org)/dashboard/dashboard-data";

interface SharingDialogProps {
isOpen: boolean;
Expand All @@ -32,6 +34,8 @@ interface SharingDialogProps {
organizationId: string;
}[];
onSharingUpdated: (updatedSharedSpaces: string[]) => void;
isPublic?: boolean;
spacesData?: Spaces[] | null;
}

export const SharingDialog: React.FC<SharingDialogProps> = ({
Expand All @@ -41,17 +45,22 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
capName,
sharedSpaces,
onSharingUpdated,
isPublic = false,
spacesData: propSpacesData = null,
}) => {
const { spacesData } = useDashboardContext();
const { spacesData: contextSpacesData } = useDashboardContext();
const spacesData = propSpacesData || contextSpacesData;
const [selectedSpaces, setSelectedSpaces] = useState<Set<string>>(new Set());
const [searchTerm, setSearchTerm] = useState("");
const [initialSelectedSpaces, setInitialSelectedSpaces] = useState<
Set<string>
>(new Set());
const [loading, setLoading] = useState(false);
const tabs = ["Share to space", "Embed"] as const;
const [publicToggle, setPublicToggle] = useState(isPublic);
const [initialPublicState, setInitialPublicState] = useState(isPublic);
const tabs = ["Share", "Embed"] as const;
const [activeTab, setActiveTab] =
useState<(typeof tabs)[number]>("Share to space");
useState<(typeof tabs)[number]>("Share");

const sharedSpaceIds = new Set(sharedSpaces?.map((space) => space.id) || []);

Expand All @@ -60,10 +69,12 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
const spaceIds = new Set(sharedSpaces.map((space) => space.id));
setSelectedSpaces(spaceIds);
setInitialSelectedSpaces(spaceIds);
setPublicToggle(isPublic);
setInitialPublicState(isPublic);
setSearchTerm("");
setActiveTab(tabs[0]);
}
}, [isOpen, sharedSpaces]);
}, [isOpen, sharedSpaces, isPublic]);

const isSpaceSharedViaOrganization = useCallback(
(spaceId: string) => {
Expand Down Expand Up @@ -92,6 +103,7 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
const result = await shareCap({
capId,
spaceIds: Array.from(selectedSpaces),
public: publicToggle,
});

if (!result.success) {
Expand All @@ -108,22 +120,26 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
(id) => !newSelectedSpaces.includes(id)
);

const publicChanged = publicToggle !== initialPublicState;

const getSpaceName = (id: string) => {
const space = spacesData?.find((space) => space.id === id);
return space?.name || `Space ${id}`;
};

if (addedSpaceIds.length === 1 && removedSpaceIds.length === 0) {
if (publicChanged && addedSpaceIds.length === 0 && removedSpaceIds.length === 0) {
toast.success(publicToggle ? "Video is now public" : "Video is now private");
} else if (addedSpaceIds.length === 1 && removedSpaceIds.length === 0 && !publicChanged) {
toast.success(`Shared to ${getSpaceName(addedSpaceIds[0] as string)}`);
} else if (removedSpaceIds.length === 1 && addedSpaceIds.length === 0) {
} else if (removedSpaceIds.length === 1 && addedSpaceIds.length === 0 && !publicChanged) {
toast.success(
`Unshared from ${getSpaceName(removedSpaceIds[0] as string)}`
);
} else if (addedSpaceIds.length > 0 && removedSpaceIds.length === 0) {
} else if (addedSpaceIds.length > 0 && removedSpaceIds.length === 0 && !publicChanged) {
toast.success(`Shared to ${addedSpaceIds.length} spaces`);
} else if (removedSpaceIds.length > 0 && addedSpaceIds.length === 0) {
} else if (removedSpaceIds.length > 0 && addedSpaceIds.length === 0 && !publicChanged) {
toast.success(`Unshared from ${removedSpaceIds.length} spaces`);
} else if (addedSpaceIds.length > 0 && removedSpaceIds.length > 0) {
} else if (addedSpaceIds.length > 0 || removedSpaceIds.length > 0 || publicChanged) {
toast.success(`Sharing settings updated`);
} else {
toast.info("No changes to sharing settings");
Expand All @@ -139,7 +155,7 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({

const handleCopyEmbedCode = async () => {
const embedCode = `<div style="position: relative; padding-bottom: 56.25%; height: 0;"><iframe src="${process.env.NODE_ENV === "development"
? "http://localhost:3000"
? process.env.NEXT_PUBLIC_WEB_URL
: "https://cap.so"
}/embed/${capId}" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>`;

Expand All @@ -151,25 +167,38 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
}
};

// Separate organization entries from real spaces
const organizationEntries = spacesData?.filter((space) =>
space.id === space.organizationId && space.primary === true
) || [];

const realSpaces = spacesData?.filter((space) =>
!(space.id === space.organizationId && space.primary === true)
) || [];

const allShareableItems = [...organizationEntries, ...realSpaces];

const filteredSpaces = searchTerm
? spacesData?.filter((space) =>
? allShareableItems.filter((space) =>
space.name.toLowerCase().includes(searchTerm.toLowerCase())
)
: spacesData;
: allShareableItems;



return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="p-0 w-full max-w-md rounded-xl border bg-gray-2 border-gray-4">
<DialogHeader
icon={<FontAwesomeIcon icon={faShareNodes} className="size-3.5" />}
description={
activeTab === "Share to space"
? "Select the spaces you would like to share with"
activeTab === "Share"
? "Select how you would like to share the cap"
: "Copy the embed code to share your cap"
}
>
<DialogTitle className="truncate w-full max-w-[320px]">
{activeTab === "Share to space"
{activeTab === "Share"
? `Share ${capName}`
: `Embed ${capName}`}
</DialogTitle>
Expand Down Expand Up @@ -202,12 +231,29 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
</div>

<div className="p-5">
{activeTab === "Share to space" ? (
{activeTab === "Share" ? (
<>
{/* Public sharing toggle */}
<div className="flex items-center justify-between p-3 mb-4 rounded-lg border bg-gray-1 border-gray-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-3">
<Globe2 className="w-4 h-4 text-gray-11" />
</div>
<div>
<p className="text-sm font-medium text-gray-12">Anyone with the link</p>
<p className="text-xs text-gray-10">{publicToggle ? 'Anyone on the internet with the link can view': 'Only people with access can view'}</p>
</div>
</div>
<Switch
checked={publicToggle}
onCheckedChange={setPublicToggle}
/>
</div>

<div className="relative mb-3">
<Input
type="text"
placeholder="Search..."
placeholder="Search and add to spaces..."
value={searchTerm}
className="pr-8"
onChange={(e) => setSearchTerm(e.target.value)}
Expand All @@ -233,7 +279,7 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
) : (
<div className="flex col-span-5 gap-2 justify-center items-center text-sm">
<p className="text-gray-12">
{spacesData && spacesData.length > 0
{allShareableItems && allShareableItems.length > 0
? "No spaces match your search"
: "No spaces available"}
</p>
Expand All @@ -246,7 +292,7 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
<div className="p-3 rounded-lg border bg-gray-3 border-gray-4">
<code className="font-mono text-xs break-all text-gray-11">
{`<div style="position: relative; padding-bottom: 56.25%; height: 0;"><iframe src="${process.env.NODE_ENV === "development"
? "http://localhost:3000"
? process.env.NEXT_PUBLIC_WEB_URL
: "https://cap.so"
}/embed/${capId}" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>`}
</code>
Expand All @@ -264,7 +310,7 @@ export const SharingDialog: React.FC<SharingDialogProps> = ({
</div>

<DialogFooter className="p-5 border-t border-gray-4">
{activeTab === "Share to space" ? (
{activeTab === "Share" ? (
<>
<Button size="sm" variant="gray" onClick={onClose}>
Cancel
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/(org)/dashboard/caps/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export default async function CapsPage({
name: videos.name,
createdAt: videos.createdAt,
metadata: videos.metadata,
public: videos.public,
totalComments: sql<number>`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`,
totalReactions: sql<number>`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`,
sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>`
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/desktop/[...route]/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ app.get(
: undefined,
isScreenshot,
bucket: customBucket?.id,
public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
metadata: {
duration,
},
Expand Down
15 changes: 11 additions & 4 deletions apps/web/app/embed/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { VideoMetadata } from "@cap/database/types";
import { getCurrentUser } from "@cap/database/auth/session";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { buildEnv } from "@cap/env";
import { transcribeVideo } from "@/actions/videos/transcribe";
import { isAiGenerationEnabled } from "@/utils/flags";
Expand Down Expand Up @@ -41,15 +42,18 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return notFound();
}

if (video.public === false) {
const userPromise = getCurrentUser();
const userAccess = await userHasAccessToVideo(userPromise, video);

if (video.public === false && userAccess !== "has-access") {
return {
title: "Cap: This video is private",
description: "This video is private and cannot be shared.",
robots: "noindex, nofollow",
};
}

if (video.password !== null) {
if (video.password !== null && userAccess !== "has-access") {
return {
title: "Cap: Password Protected Video",
description: "This video is password protected.",
Expand Down Expand Up @@ -159,8 +163,11 @@ export default async function EmbedVideoPage(props: Props) {

if (userAccess === "private") {
return (
<div className="min-h-screen flex items-center justify-center bg-black text-white">
<p>This video is private</p>
<div className="flex flex-col justify-center items-center min-h-screen text-center bg-black text-white">
<h1 className="mb-4 text-2xl font-bold">This video is private</h1>
<p className="text-gray-400">
If you own this video, please <Link href="/login">sign in</Link> to manage sharing.
</p>
</div>
);
}
Expand Down
Loading
Loading