Skip to content
Closed
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
72 changes: 72 additions & 0 deletions apps/web/actions/folders/share-folder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use server";

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { folders, videos } from "@cap/database/schema";
import type { Folder } from "@cap/web-domain";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { signFolderShareSlug } from "@/lib/folder-share";

const requireOwnedFolder = async (folderId: Folder.FolderId) => {
const user = await getCurrentUser();
if (!user || !user.activeOrganizationId)
throw new Error("Unauthorized or no active organization");

const [folder] = await db()
.select()
.from(folders)
.where(
and(
eq(folders.id, folderId),
eq(folders.organizationId, user.activeOrganizationId),
),
);

if (!folder) throw new Error("Folder not found");
return folder;
};

export async function getFolderShareState(folderId: Folder.FolderId) {
const folder = await requireOwnedFolder(folderId);
return {
isShared: folder.publicShared === true,
slug: folder.publicShared ? signFolderShareSlug(folderId) : null,
};
}

export async function enableFolderSharing(folderId: Folder.FolderId) {
await requireOwnedFolder(folderId);
await db()
.update(folders)
.set({ publicShared: true })
.where(eq(folders.id, folderId));
await db()
.update(videos)
.set({ public: true, publicSourcedFromFolderShare: true })
.where(
and(eq(videos.folderId, folderId), eq(videos.public, false)),
);
revalidatePath(`/dashboard/folder/${folderId}`);
revalidatePath(`/dashboard/caps`);
return { slug: signFolderShareSlug(folderId) };
}

export async function disableFolderSharing(folderId: Folder.FolderId) {
await requireOwnedFolder(folderId);
await db()
.update(folders)
.set({ publicShared: false })
.where(eq(folders.id, folderId));
await db()
.update(videos)
.set({ public: false, publicSourcedFromFolderShare: false })
.where(
and(
eq(videos.folderId, folderId),
eq(videos.publicSourcedFromFolderShare, true),
),
);
revalidatePath(`/dashboard/folder/${folderId}`);
revalidatePath(`/dashboard/caps`);
}
8 changes: 8 additions & 0 deletions apps/web/app/(org)/dashboard/caps/components/Folder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ConfirmationDialog } from "../../_components/ConfirmationDialog";
import { useDashboardContext, useTheme } from "../../Contexts";
import { registerDropTarget } from "../../folder/[id]/components/ClientCapCard";
import { FoldersDropdown } from "./FoldersDropdown";
import { ShareFolderDialog } from "./ShareFolderDialog";

export type FolderDataType = {
name: string;
Expand All @@ -35,6 +36,7 @@ const FolderCard = ({
const router = useRouter();
const { theme } = useTheme();
const [confirmDeleteFolderOpen, setConfirmDeleteFolderOpen] = useState(false);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [updateName, setUpdateName] = useState(name);
const nameRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -402,8 +404,14 @@ const FolderCard = ({
parentId={parentId}
setIsRenaming={setIsRenaming}
setConfirmDeleteFolderOpen={setConfirmDeleteFolderOpen}
setShareDialogOpen={spaceId ? undefined : setShareDialogOpen}
nameRef={nameRef}
/>
<ShareFolderDialog
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
folderId={id}
/>
</div>
</Link>
);
Expand Down
12 changes: 12 additions & 0 deletions apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import {
faEllipsis,
faPencil,
faShareNodes,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
Expand All @@ -16,13 +17,15 @@ interface FoldersDropdownProps {
id: string;
setIsRenaming: (isRenaming: boolean) => void;
setConfirmDeleteFolderOpen: (open: boolean) => void;
setShareDialogOpen?: (open: boolean) => void;
nameRef: RefObject<HTMLTextAreaElement | null>;
parentId?: string | null;
}

export const FoldersDropdown = ({
setIsRenaming,
setConfirmDeleteFolderOpen,
setShareDialogOpen,
nameRef,
}: FoldersDropdownProps) => {
return (
Expand Down Expand Up @@ -62,6 +65,15 @@ export const FoldersDropdown = ({
}, 0);
},
},
...(setShareDialogOpen
? [
{
label: "Share folder",
icon: faShareNodes,
onClick: () => setShareDialogOpen(true),
} satisfies FolderDropdownItem,
]
: []),
// Only show Duplicate if there is NO active space
// ...(!activeSpace
// ? [
Expand Down
167 changes: 167 additions & 0 deletions apps/web/app/(org)/dashboard/caps/components/ShareFolderDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"use client";

import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@cap/ui";
import type { Folder } from "@cap/web-domain";
import {
faCopy,
faShareNodes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
disableFolderSharing,
enableFolderSharing,
getFolderShareState,
} from "@/actions/folders/share-folder";

interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
folderId: Folder.FolderId;
}

export const ShareFolderDialog: React.FC<Props> = ({
open,
onOpenChange,
folderId,
}) => {
const [isShared, setIsShared] = useState(false);
const [slug, setSlug] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [hydrated, setHydrated] = useState(false);

useEffect(() => {
if (!open) return;
setHydrated(false);
getFolderShareState(folderId)
.then((state) => {
setIsShared(state.isShared);
setSlug(state.slug);
})
.catch(() => toast.error("Failed to load share state"))
.finally(() => setHydrated(true));
}, [open, folderId]);

const baseUrl =
typeof window !== "undefined" ? window.location.origin : "";
const shareUrl = slug ? `${baseUrl}/share/f/${slug}` : "";

const handleEnable = async () => {
setLoading(true);
try {
const res = await enableFolderSharing(folderId);
setSlug(res.slug);
setIsShared(true);
toast.success("Folder shared. Link copied to clipboard.");
try {
await navigator.clipboard.writeText(`${baseUrl}/share/f/${res.slug}`);
} catch {}
} catch {
toast.error("Failed to enable sharing");
} finally {
setLoading(false);
}
};

const handleDisable = async () => {
setLoading(true);
try {
await disableFolderSharing(folderId);
setIsShared(false);
setSlug(null);
toast.success("Sharing disabled");
} catch {
toast.error("Failed to disable sharing");
} finally {
setLoading(false);
}
};

const handleCopy = async () => {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success("Link copied");
} catch {
toast.error("Failed to copy");
}
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100%-20px)] max-w-[480px]">
<DialogHeader
icon={<FontAwesomeIcon icon={faShareNodes} className="size-3.5" />}
>
<DialogTitle>Share folder</DialogTitle>
</DialogHeader>
<div className="p-5 space-y-4">
<p className="text-sm text-gray-10">
Anyone with the link can view every video in this folder in
read-only mode. No account required. Enabling will also flip
every video in the folder to public; disabling reverts them.
</p>
{hydrated && isShared && shareUrl ? (
<div className="space-y-2">
<label className="text-xs font-medium text-gray-11">
Public link
</label>
<div className="flex gap-2">
<Input value={shareUrl} readOnly className="flex-1 text-xs" />
<Button
size="sm"
variant="gray"
onClick={handleCopy}
className="flex gap-1.5 items-center"
>
<FontAwesomeIcon icon={faCopy} className="size-3" />
Copy
</Button>
</div>
</div>
) : null}
</div>
<DialogFooter>
<Button
size="sm"
variant="gray"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Close
</Button>
{hydrated && isShared ? (
<Button
size="sm"
variant="destructive"
onClick={handleDisable}
spinner={loading}
disabled={loading}
>
Disable sharing
</Button>
) : (
<Button
size="sm"
variant="dark"
onClick={handleEnable}
spinner={loading}
disabled={loading || !hydrated}
>
Enable sharing
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
1 change: 1 addition & 0 deletions apps/web/app/embed/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export default async function EmbedVideoPage(
transcriptionStatus: videos.transcriptionStatus,
source: videos.source,
folderId: videos.folderId,
publicSourcedFromFolderShare: videos.publicSourcedFromFolderShare,
width: videos.width,
height: videos.height,
duration: videos.duration,
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/s/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) {
storageIntegrationId: videos.storageIntegrationId,
metadata: videos.metadata,
public: videos.public,
publicSourcedFromFolderShare: videos.publicSourcedFromFolderShare,
videoStartTime: videos.videoStartTime,
audioStartTime: videos.audioStartTime,
awsRegion: videos.awsRegion,
Expand Down
Loading