diff --git a/apps/web/actions/account/remove-profile-image.ts b/apps/web/actions/account/remove-profile-image.ts index 268bf7b023..6689ab9dc9 100644 --- a/apps/web/actions/account/remove-profile-image.ts +++ b/apps/web/actions/account/remove-profile-image.ts @@ -1,10 +1,14 @@ "use server"; +import path from "node:path"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users } from "@cap/database/schema"; +import { S3Buckets } from "@cap/web-backend"; import { eq } from "drizzle-orm"; +import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; +import { runPromise } from "@/lib/server"; export async function removeProfileImage() { const user = await getCurrentUser(); @@ -13,10 +17,50 @@ export async function removeProfileImage() { throw new Error("Unauthorized"); } + const image = user.image; + + // Delete the profile image from S3 if it exists + if (image) { + try { + // Extract the S3 key - handle both old URL format and new key format + let s3Key = image; + if (image.startsWith("http://") || image.startsWith("https://")) { + const url = new URL(image); + // Only extract key from URLs with amazonaws.com hostname + if ( + url.hostname.endsWith(".amazonaws.com") || + url.hostname === "amazonaws.com" + ) { + const raw = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const decoded = decodeURIComponent(raw); + const normalized = path.posix.normalize(decoded); + if (normalized.includes("..")) { + throw new Error("Invalid S3 key path"); + } + s3Key = normalized; + } else { + // Not an S3 URL, skip deletion of S3 object; continue with DB update below + } + } + + // Only delete if it looks like a user profile image key + if (s3Key.startsWith("users/")) { + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + yield* bucket.deleteObject(s3Key); + }).pipe(runPromise); + } + } catch (error) { + console.error("Error deleting profile image from S3:", error); + // Continue with database update even if S3 deletion fails + } + } + await db().update(users).set({ image: null }).where(eq(users.id, user.id)); revalidatePath("/dashboard/settings/account"); - revalidatePath("/dashboard", "layout"); return { success: true } as const; } diff --git a/apps/web/actions/account/upload-profile-image.ts b/apps/web/actions/account/upload-profile-image.ts index f445937ead..74c2f6c603 100644 --- a/apps/web/actions/account/upload-profile-image.ts +++ b/apps/web/actions/account/upload-profile-image.ts @@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; import { S3Buckets } from "@cap/web-backend"; import { eq } from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -43,15 +42,50 @@ export async function uploadProfileImage(formData: FormData) { throw new Error("File size must be 3MB or less"); } + // Get the old profile image to delete it later + const oldImageUrlOrKey = user.image; + const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`; try { const sanitizedFile = await sanitizeFile(file); - let imageUrl: string | undefined; + let image: string | null = null; await Effect.gen(function* () { const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + // Delete old profile image if it exists + if (oldImageUrlOrKey) { + try { + // Extract the S3 key - handle both old URL format and new key format + let oldS3Key = oldImageUrlOrKey; + if ( + oldImageUrlOrKey.startsWith("http://") || + oldImageUrlOrKey.startsWith("https://") + ) { + const url = new URL(oldImageUrlOrKey); + // Only extract key from URLs with amazonaws.com hostname + if ( + url.hostname.endsWith(".amazonaws.com") || + url.hostname === "amazonaws.com" + ) { + oldS3Key = url.pathname.substring(1); // Remove leading slash + } else { + // Not an S3 URL, skip deletion + return; + } + } + + // Only delete if it looks like a user profile image key + if (oldS3Key.startsWith("users/")) { + yield* bucket.deleteObject(oldS3Key); + } + } catch (error) { + console.error("Error deleting old profile image from S3:", error); + // Continue with upload even if deletion fails + } + } + const bodyBytes = yield* Effect.promise(async () => { const buf = await sanitizedFile.arrayBuffer(); return new Uint8Array(buf); @@ -61,32 +95,23 @@ export async function uploadProfileImage(formData: FormData) { contentType: file.type, }); - if (serverEnv().CAP_AWS_BUCKET_URL) { - imageUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; - } else if (serverEnv().CAP_AWS_ENDPOINT) { - imageUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; - } else { - imageUrl = `https://${bucket.bucketName}.s3.${ - serverEnv().CAP_AWS_REGION || "us-east-1" - }.amazonaws.com/${fileKey}`; - } + image = fileKey; }).pipe(runPromise); - if (typeof imageUrl !== "string" || imageUrl.length === 0) { - throw new Error("Failed to resolve uploaded profile image URL"); + if (!image) { + throw new Error("Failed to resolve uploaded profile image key"); } - const finalImageUrl = imageUrl; + const finalImageUrlOrKey = image; await db() .update(users) - .set({ image: finalImageUrl }) + .set({ image: finalImageUrlOrKey }) .where(eq(users.id, user.id)); revalidatePath("/dashboard/settings/account"); - revalidatePath("/dashboard", "layout"); - return { success: true, imageUrl: finalImageUrl } as const; + return { success: true, image: finalImageUrlOrKey } as const; } catch (error) { console.error("Error uploading profile image:", error); throw new Error(error instanceof Error ? error.message : "Upload failed"); diff --git a/apps/web/actions/organization/create-space.ts b/apps/web/actions/organization/create-space.ts index d50b193484..940763e444 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -4,7 +4,6 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId, nanoIdLength } from "@cap/database/helpers"; import { spaceMembers, spaces, users } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; import { S3Buckets } from "@cap/web-backend"; import { Space } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; @@ -100,20 +99,7 @@ export async function createSpace( yield* Effect.promise(() => iconFile.bytes()), { contentType: iconFile.type }, ); - - // Construct the icon URL - if (serverEnv().CAP_AWS_BUCKET_URL) { - // If a custom bucket URL is defined, use it - iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; - } else if (serverEnv().CAP_AWS_ENDPOINT) { - // For custom endpoints like MinIO - iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; - } else { - // Default AWS S3 URL format - iconUrl = `https://${bucket.bucketName}.s3.${ - serverEnv().CAP_AWS_REGION || "us-east-1" - }.amazonaws.com/${fileKey}`; - } + iconUrl = fileKey; }).pipe(runPromise); } catch (error) { console.error("Error uploading space icon:", error); @@ -124,18 +110,15 @@ export async function createSpace( } } - await db() - .insert(spaces) - .values({ - id: spaceId, - name, - organizationId: user.activeOrganizationId, - createdById: user.id, - iconUrl, - description: iconUrl ? `Space with custom icon: ${iconUrl}` : null, - createdAt: new Date(), - updatedAt: new Date(), - }); + await db().insert(spaces).values({ + id: spaceId, + name, + organizationId: user.activeOrganizationId, + createdById: user.id, + iconUrl, + createdAt: new Date(), + updatedAt: new Date(), + }); // --- Member Management Logic --- // Collect member emails from formData diff --git a/apps/web/actions/organization/remove-icon.ts b/apps/web/actions/organization/remove-icon.ts index 6eba5818ce..b0c54b7423 100644 --- a/apps/web/actions/organization/remove-icon.ts +++ b/apps/web/actions/organization/remove-icon.ts @@ -3,9 +3,12 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; +import { S3Buckets } from "@cap/web-backend"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; +import { runPromise } from "@/lib/server"; export async function removeOrganizationIcon( organizationId: Organisation.OrganisationId, @@ -29,6 +32,39 @@ export async function removeOrganizationIcon( throw new Error("Only the owner can remove the organization icon"); } + const iconUrl = organization[0]?.iconUrl; + + // Delete the icon from S3 if it exists + if (iconUrl) { + try { + // Extract the S3 key - handle both old URL format and new key format + let s3Key = iconUrl; + if (iconUrl.startsWith("http://") || iconUrl.startsWith("https://")) { + const url = new URL(iconUrl); + // Only extract key from URLs with amazonaws.com hostname + if ( + url.hostname.endsWith(".amazonaws.com") || + url.hostname === "amazonaws.com" + ) { + s3Key = url.pathname.substring(1); // Remove leading slash + } else { + s3Key = ""; + } + } + + // Only delete if it looks like an organization icon key + if (s3Key.startsWith("organizations/")) { + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + yield* bucket.deleteObject(s3Key); + }).pipe(runPromise); + } + } catch (error) { + console.error("Error deleting organization icon from S3:", error); + // Continue with database update even if S3 deletion fails + } + } + // Update organization to remove icon URL await db() .update(organizations) diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index 0fe9743873..bc4805b2ef 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -52,7 +52,10 @@ export async function updateSpace(formData: FormData) { const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id)); const space = spaceArr[0]; if (space?.iconUrl) { - const key = space.iconUrl.match(/organizations\/.+/)?.[0]; + // Extract the S3 key (it might already be a key or could be a legacy URL) + const key = space.iconUrl.startsWith("organizations/") + ? space.iconUrl + : space.iconUrl.match(/organizations\/.+/)?.[0]; if (key) { try { diff --git a/apps/web/actions/organization/upload-organization-icon.ts b/apps/web/actions/organization/upload-organization-icon.ts index 83c535e31f..77b4685176 100644 --- a/apps/web/actions/organization/upload-organization-icon.ts +++ b/apps/web/actions/organization/upload-organization-icon.ts @@ -3,7 +3,6 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { organizations } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; import { S3Buckets } from "@cap/web-backend"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; @@ -52,39 +51,60 @@ export async function uploadOrganizationIcon( throw new Error("File size must be less than 1MB"); } + // Get the old icon to delete it later + const oldIconUrlOrKey = organization[0]?.iconUrl; + // Create a unique file key const fileExtension = file.name.split(".").pop(); const fileKey = `organizations/${organizationId}/icon-${Date.now()}.${fileExtension}`; try { const sanitizedFile = await sanitizeFile(file); - let iconUrl: string | undefined; await Effect.gen(function* () { const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + // Delete old icon if it exists + if (oldIconUrlOrKey) { + try { + // Extract the S3 key - handle both old URL format and new key format + let oldS3Key = oldIconUrlOrKey; + if ( + oldIconUrlOrKey.startsWith("http://") || + oldIconUrlOrKey.startsWith("https://") + ) { + const url = new URL(oldIconUrlOrKey); + // Only extract key from URLs with amazonaws.com hostname + if ( + url.hostname.endsWith(".amazonaws.com") || + url.hostname === "amazonaws.com" + ) { + oldS3Key = url.pathname.substring(1); // Remove leading slash + } else { + return; + } + } + + // Only delete if it looks like an organization icon key + if (oldS3Key.startsWith("organizations/")) { + yield* bucket.deleteObject(oldS3Key); + } + } catch (error) { + console.error("Error deleting old organization icon from S3:", error); + // Continue with upload even if deletion fails + } + } + const bodyBytes = yield* Effect.promise(async () => { const buf = await sanitizedFile.arrayBuffer(); return new Uint8Array(buf); }); yield* bucket.putObject(fileKey, bodyBytes, { contentType: file.type }); - // Construct the icon URL - if (serverEnv().CAP_AWS_BUCKET_URL) { - // If a custom bucket URL is defined, use it - iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; - } else if (serverEnv().CAP_AWS_ENDPOINT) { - // For custom endpoints like MinIO - iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; - } else { - // Default AWS S3 URL format - iconUrl = `https://${bucket.bucketName}.s3.${ - serverEnv().CAP_AWS_REGION || "us-east-1" - }.amazonaws.com/${fileKey}`; - } }).pipe(runPromise); - // Update organization with new icon URL + const iconUrl = fileKey; + await db() .update(organizations) .set({ iconUrl }) diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index 395d668026..8918859144 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -65,8 +65,10 @@ export async function uploadSpaceIcon( try { // Remove previous icon if exists if (space.iconUrl) { - // Try to extract the previous S3 key from the URL - const key = space.iconUrl.match(/organizations\/.+/)?.[0]; + // Extract the S3 key (it might already be a key or could be a legacy URL) + const key = space.iconUrl.startsWith("organizations/") + ? space.iconUrl + : space.iconUrl.match(/organizations\/.+/)?.[0]; if (key) { try { await bucket.deleteObject(key).pipe(runPromise); @@ -87,20 +89,8 @@ export async function uploadSpaceIcon( ) .pipe(runPromise); - let iconUrl: string | undefined; - - // Construct the icon URL - if (serverEnv().CAP_AWS_BUCKET_URL) { - iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`; - } else if (serverEnv().CAP_AWS_ENDPOINT) { - iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`; - } else { - iconUrl = `https://${bucket.bucketName}.s3.${ - serverEnv().CAP_AWS_REGION || "us-east-1" - }.amazonaws.com/${fileKey}`; - } + const iconUrl = fileKey; - // Update space with new icon URL await db().update(spaces).set({ iconUrl }).where(eq(spaces.id, spaceId)); revalidatePath("/dashboard"); diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 9f83404a9e..742753e1fc 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -67,7 +67,7 @@ export async function newComment(data: { const commentWithAuthor = { ...newComment, authorName: user.name, - authorImage: user.image ?? null, + authorImageUrlOrKey: user.image ?? null, sending: false, }; diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index b474227c5a..ce1b469a9a 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -1,11 +1,9 @@ "use client"; -import { Avatar } from "@cap/ui"; import { useClickAway } from "@uidotdev/usehooks"; import clsx from "clsx"; import { Check, ChevronDown } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -16,6 +14,7 @@ import { useRef, useState, } from "react"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../Contexts"; import { CapIcon, CogIcon, LayersIcon } from "./AnimatedIcons"; import { updateActiveOrganization } from "./Navbar/server"; @@ -76,22 +75,13 @@ const Orgs = ({ ref={containerRef} className="flex gap-1.5 items-center p-2 rounded-full border bg-gray-3 border-gray-5" > - {activeOrg?.organization.iconUrl ? ( -
- {activeOrg.organization.name -
- ) : ( - - )} +

{activeOrg?.organization.name}

@@ -145,22 +135,13 @@ const OrgsMenu = ({ }} >
- {organization.organization.iconUrl ? ( -
- {organization.organization.name -
- ) : ( - - )} +

{ )} >

- {activeOrg?.organization.iconUrl ? ( -
- { -
- ) : ( - - )} +
@@ -230,25 +213,13 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { }} >
- {organization.organization.iconUrl ? ( -
- { -
- ) : ( - - )} +

(null); const [spaceName, setSpaceName] = useState(space?.name || ""); - // Reset spaceName when dialog opens or space changes - React.useEffect(() => { + useEffect(() => { setSpaceName(space?.name || ""); - }, [space, open]); + }, [space]); return (

!open && onClose()}> @@ -141,7 +142,7 @@ export const NewSpaceForm: React.FC = (props) => { mode: "onChange", }); - React.useEffect(() => { + useEffect(() => { if (space) { form.reset({ name: space.name, @@ -255,7 +256,7 @@ export const NewSpaceForm: React.FC = (props) => { .map((m) => ({ value: m.user.id, label: m.user.name || m.user.email, - image: m.user.image || undefined, + image: m.user.image ?? undefined, }))} onSelect={(selected) => field.onChange(selected.map((opt) => opt.value)) @@ -275,8 +276,9 @@ export const NewSpaceForm: React.FC = (props) => {
void }) => { space.primary ? "h-10" : "h-fit", )} > - {space.iconUrl ? ( - {space.name} - ) : ( - - )} + {!sidebarCollapsed && ( <> @@ -358,6 +350,10 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { setShowSpaceDialog(false)} + onSpaceUpdated={() => { + router.refresh(); + setShowSpaceDialog(false); + }} />
); diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 59bf7b4f7a..c1bc6469ba 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -2,7 +2,6 @@ import { buildEnv } from "@cap/env"; import { - Avatar, Command, CommandGroup, CommandItem, @@ -17,7 +16,6 @@ import { useClickAway } from "@uidotdev/usehooks"; import clsx from "clsx"; import { AnimatePresence } from "framer-motion"; import { MoreVertical } from "lucide-react"; -import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; @@ -32,6 +30,7 @@ import { } from "react"; import { markAsRead } from "@/actions/notifications/mark-as-read"; import Notifications from "@/app/(org)/dashboard/_components/Notifications"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { UpgradeModal } from "@/components/UpgradeModal"; import { useDashboardContext, useTheme } from "../../Contexts"; import { @@ -101,22 +100,15 @@ const Top = () => {
{activeSpace && Space}
- {activeSpace && - (activeSpace.iconUrl ? ( - {activeSpace?.name - ) : ( - - ))} + {activeSpace && ( + + )}

{title}

@@ -269,10 +261,11 @@ const User = () => { className="flex gap-2 justify-between items-center p-2 rounded-xl border data-[state=open]:border-gray-3 data-[state=open]:bg-gray-3 border-transparent transition-colors cursor-pointer group lg:gap-6 hover:border-gray-3" >
- diff --git a/apps/web/app/(org)/dashboard/_components/Notifications/Skeleton.tsx b/apps/web/app/(org)/dashboard/_components/Notifications/Skeleton.tsx index 444283dfc3..16da8109d9 100644 --- a/apps/web/app/(org)/dashboard/_components/Notifications/Skeleton.tsx +++ b/apps/web/app/(org)/dashboard/_components/Notifications/Skeleton.tsx @@ -59,7 +59,7 @@ export const NotificationsSkeleton = ({ count = 5 }: { count?: number }) => {
{Array.from({ length: count }).map((_, i) => ( ))} diff --git a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx index b3d7c0ca57..5db9749c08 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -16,12 +16,12 @@ import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; import { motion } from "framer-motion"; import { Check, Globe2, Search } 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 type { Spaces } from "@/app/(org)/dashboard/dashboard-data"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; interface SharingDialogProps { @@ -262,9 +262,9 @@ export const SharingDialog: React.FC = ({ {activeTab === "Share" ? ( <> {/* Public sharing toggle */} -
-
-
+
+
+
@@ -416,23 +416,13 @@ const SpaceCard = ({ )} onClick={() => handleToggleSpace(space.id)} > - {space.iconUrl ? ( -
- {space.name} -
- ) : ( - - )} +

{space.name}

diff --git a/apps/web/app/(org)/dashboard/caps/loading.tsx b/apps/web/app/(org)/dashboard/caps/loading.tsx index 0d5d065640..026044fa03 100644 --- a/apps/web/app/(org)/dashboard/caps/loading.tsx +++ b/apps/web/app/(org)/dashboard/caps/loading.tsx @@ -27,7 +27,7 @@ export default function Loading() { .fill(0) .map((_, index) => ( (
{/* Thumbnail */} diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index 0f7655c6d9..ffb09e8ae1 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -47,6 +47,7 @@ export async function getDashboardData(user: typeof userSelectProps) { organization: organizations, settings: organizations.settings, member: organizationMembers, + iconUrl: organizations.iconUrl, user: { id: users.id, name: users.name, @@ -130,15 +131,17 @@ export async function getDashboardData(user: typeof userSelectProps) { organizationId: spaces.organizationId, createdById: spaces.createdById, iconUrl: spaces.iconUrl, + memberImage: users.image, memberCount: sql`( - SELECT COUNT(*) FROM space_members WHERE space_members.spaceId = spaces.id - )`, + SELECT COUNT(*) FROM space_members WHERE space_members.spaceId = spaces.id + )`, videoCount: sql`( - SELECT COUNT(*) FROM space_videos WHERE space_videos.spaceId = spaces.id - )`, + SELECT COUNT(*) FROM space_videos WHERE space_videos.spaceId = spaces.id + )`, }) .from(spaces) .leftJoin(spaceMembers, eq(spaces.id, spaceMembers.spaceId)) + .leftJoin(users, eq(spaceMembers.userId, users.id)) .where( and( eq(spaces.organizationId, activeOrganizationId), @@ -152,7 +155,7 @@ export async function getDashboardData(user: typeof userSelectProps) { ), ), ) - .groupBy(spaces.id); + .groupBy(spaces.id, users.image); // Add a single 'All spaces' entry for the active organization const activeOrgInfo = organizationsWithMembers.find( @@ -203,7 +206,7 @@ export async function getDashboardData(user: typeof userSelectProps) { name: `All ${activeOrgInfo.organization.name}`, description: `View all content in ${activeOrgInfo.organization.name}`, organizationId: activeOrgInfo.organization.id, - iconUrl: null, + iconUrl: activeOrgInfo.organization.iconUrl, memberCount: orgMemberCount, createdById: activeOrgInfo.organization.ownerId, videoCount: orgVideoCount, diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx index 9d135f4c14..ca6c2de19a 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx @@ -3,12 +3,12 @@ import { Avatar } from "@cap/ui"; import type { Space, Video } from "@cap/web-domain"; import clsx from "clsx"; -import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../../Contexts"; import { registerDropTarget } from "./ClientCapCard"; @@ -124,23 +124,14 @@ export function ClientMyCapsLink({ onDragLeave={handleDragLeave} onDrop={handleDrop} > - {activeSpace && activeSpace.iconUrl ? ( - {activeSpace.name - ) : ( - activeSpace && - !activeSpace.iconUrl && ( - - ) )} {activeSpace ? activeSpace.name : "My Caps"} diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 114e02d5a3..8c4c56b280 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -116,7 +116,7 @@ export const Settings = ({ const removeProfileImageMutation = useMutation({ mutationFn: removeProfileImage, onSuccess: (result) => { - if (result.success) { + if (result?.success) { setProfileImageOverride(null); toast.success("Profile image removed"); router.refresh(); @@ -174,6 +174,7 @@ export const Settings = ({ disabled={isProfileImageMutating} isUploading={uploadProfileImageMutation.isPending} isRemoving={removeProfileImageMutation.isPending} + userName={user?.name} /> diff --git a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx index e2a6948e11..d494ab89a6 100644 --- a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx @@ -4,9 +4,9 @@ import { Button } from "@cap/ui"; import { faImage, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; -import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; interface ProfileImageProps { @@ -16,6 +16,7 @@ interface ProfileImageProps { disabled?: boolean; isUploading?: boolean; isRemoving?: boolean; + userName?: string | null; } export function ProfileImage({ @@ -25,16 +26,19 @@ export function ProfileImage({ disabled = false, isUploading = false, isRemoving = false, + userName, }: ProfileImageProps) { const [previewUrl, setPreviewUrl] = useState( initialPreviewUrl || null, ); + const [isLocalPreview, setIsLocalPreview] = useState(false); const fileInputRef = useRef(null); // Reset isRemoving when the parent confirms the operation completed useEffect(() => { if (initialPreviewUrl !== undefined) { setPreviewUrl(initialPreviewUrl); + setIsLocalPreview(false); } }, [initialPreviewUrl]); @@ -46,16 +50,21 @@ export function ProfileImage({ toast.error("File size must be 1MB or less"); return; } - if (previewUrl && previewUrl !== initialPreviewUrl) { + if (previewUrl && isLocalPreview) { URL.revokeObjectURL(previewUrl); } const objectUrl = URL.createObjectURL(file); setPreviewUrl(objectUrl); + setIsLocalPreview(true); onChange?.(file); }; const handleRemove = () => { + if (previewUrl && isLocalPreview) { + URL.revokeObjectURL(previewUrl); + } setPreviewUrl(null); + setIsLocalPreview(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -80,15 +89,20 @@ export function ProfileImage({ )} > {previewUrl ? ( - Profile Image ) : ( - +
+ +
)}
{ + const router = useRouter(); const iconInputId = useId(); const { activeOrganization } = useDashboardContext(); const organizationId = activeOrganization?.organization.id; - const existingIconUrl = activeOrganization?.organization.iconUrl; + const existingIconUrl = activeOrganization?.organization.iconUrl ?? null; const [isUploading, setIsUploading] = useState(false); @@ -29,6 +31,7 @@ export const OrganizationIcon = () => { if (result.success) { toast.success("Organization icon updated successfully"); + router.refresh(); } } catch (error) { toast.error( @@ -45,8 +48,9 @@ export const OrganizationIcon = () => { try { const result = await removeOrganizationIcon(organizationId); - if (result.success) { + if (result?.success) { toast.success("Organization icon removed successfully"); + router.refresh(); } } catch (error) { console.error("Error removing organization icon:", error); @@ -69,10 +73,11 @@ export const OrganizationIcon = () => { previewIconSize={20} id={iconInputId} name="icon" + type="organization" onChange={handleFileChange} disabled={isUploading} isLoading={isUploading} - initialPreviewUrl={existingIconUrl || null} + initialPreviewUrl={existingIconUrl} onRemove={handleRemoveIcon} maxFileSizeBytes={1 * 1024 * 1024} // 1MB /> diff --git a/apps/web/app/(org)/dashboard/settings/organization/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/page.tsx index b2c0e29f13..8e134c645f 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/page.tsx @@ -34,7 +34,7 @@ export default async function OrganizationPage() { ), ); - if (member?.role !== "owner") { + if (!member || member.role !== "owner") { redirect("/dashboard/caps"); } diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx index 3ef262d732..0f372b7c30 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MemberSelect.tsx @@ -1,7 +1,6 @@ "use client"; import { - Avatar, Button, DropdownMenu, DropdownMenuContent, @@ -13,6 +12,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { ChevronDown } from "lucide-react"; import { forwardRef, useEffect, useRef, useState } from "react"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../../Contexts"; // Define types for organization member objects @@ -195,9 +195,10 @@ export const MemberSelect = forwardRef( key={opt.value} className="flex gap-2 items-center justify-start p-1.5 text-[13px] rounded-xl cursor-pointer" > - @@ -218,9 +219,10 @@ export const MemberSelect = forwardRef( className="flex gap-4 items-center hover:scale-[1.02] transition-transform h-full px-2 py-1.5 min-h-full text-xs rounded-xl bg-gray-3 text-gray-11 wobble" >
- diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx index 16aaf34657..c6bec9528f 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersDialog.tsx @@ -1,10 +1,5 @@ -import { - Avatar, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@cap/ui"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@cap/ui"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; interface OrganizationMember { id: string; @@ -19,6 +14,7 @@ interface OrganizationMember { email: string; firstName?: string | null; lastName?: string | null; + memberImage?: string | null; }; } @@ -46,10 +42,12 @@ export const MembersDialog = ({ key={member.userId} className="flex items-center p-2 rounded-lg hover:bg-gray-3" > -
diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx index 81bf454abd..885d7c7c02 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx @@ -1,7 +1,6 @@ "use client"; import { - Avatar, Button, Dialog, DialogContent, @@ -21,6 +20,7 @@ import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../../Contexts"; import { setSpaceMembers } from "../actions"; import type { SpaceMemberData } from "../page"; @@ -165,9 +165,10 @@ export const MembersIndicator = ({ key={member.userId} className="flex gap-2 items-center p-3 rounded-lg border bg-gray-3 border-gray-4" > - diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx index 502522475a..33d57ce70f 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx @@ -1,7 +1,6 @@ "use client"; import { - Avatar, Button, Dialog, DialogContent, @@ -13,8 +12,8 @@ import { import { faBuilding, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; -import Image from "next/image"; import { useState } from "react"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../../Contexts"; export type OrganizationMemberData = { @@ -73,21 +72,13 @@ export const OrganizationIndicator = ({ key={member.id} className="flex gap-3 items-center px-3 py-2 rounded-lg border transition-colors bg-gray-3 border-gray-4" > - {member.image ? ( - {member.name - ) : ( - - )} +

{member.name || member.email} @@ -101,7 +92,9 @@ export const OrganizationIndicator = ({

{member.role} diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/loading.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/loading.tsx index 0d5d065640..026044fa03 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/loading.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/loading.tsx @@ -27,7 +27,7 @@ export default function Loading() { .fill(0) .map((_, index) => ( (

{/* Thumbnail */} diff --git a/apps/web/app/(org)/dashboard/spaces/browse/loading.tsx b/apps/web/app/(org)/dashboard/spaces/browse/loading.tsx index b4120922c0..539afdbd2c 100644 --- a/apps/web/app/(org)/dashboard/spaces/browse/loading.tsx +++ b/apps/web/app/(org)/dashboard/spaces/browse/loading.tsx @@ -75,7 +75,7 @@ export default function Loading() { .fill(0) .map((_, index) => (
diff --git a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx index caf8debbcb..b34d20368d 100644 --- a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx @@ -9,12 +9,12 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Search } from "lucide-react"; -import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { deleteSpace } from "@/actions/organization/delete-space"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { ConfirmationDialog } from "../../_components/ConfirmationDialog"; import SpaceDialog from "../../_components/Navbar/SpaceDialog"; import { useDashboardContext } from "../../Contexts"; @@ -132,21 +132,13 @@ export default function BrowseSpacesPage() { className="border-t transition-colors cursor-pointer hover:bg-gray-2 border-gray-3" > - {space.iconUrl ? ( - {space.name} - ) : ( - - )} + {space.name} @@ -197,7 +189,7 @@ export default function BrowseSpacesPage() {
) : ( -
+

...

)} diff --git a/apps/web/app/(org)/onboarding/components/Bottom.tsx b/apps/web/app/(org)/onboarding/components/Bottom.tsx index c2f1f51bf1..c1a1eb5ed1 100644 --- a/apps/web/app/(org)/onboarding/components/Bottom.tsx +++ b/apps/web/app/(org)/onboarding/components/Bottom.tsx @@ -43,7 +43,7 @@ export const Bottom = () => {