From a03f7808c13586265c92dd854586e49f1014022c Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:36:03 +0300 Subject: [PATCH 1/9] handle icon urls with keys instead --- .../actions/account/remove-profile-image.ts | 34 +++- .../actions/account/upload-profile-image.ts | 47 +++-- apps/web/actions/organization/create-space.ts | 28 +-- apps/web/actions/organization/remove-icon.ts | 30 ++- apps/web/actions/organization/update-space.ts | 14 +- .../organization/upload-organization-icon.ts | 45 +++-- .../actions/organization/upload-space-icon.ts | 29 ++- apps/web/actions/videos/new-comment.ts | 2 +- .../(org)/dashboard/_components/MobileTab.tsx | 54 +++--- .../dashboard/_components/Navbar/Items.tsx | 78 +++----- .../_components/Navbar/SpaceDialog.tsx | 23 +-- .../_components/Navbar/SpacesList.tsx | 37 ++-- .../dashboard/_components/Navbar/Top.tsx | 28 +-- apps/web/app/(org)/dashboard/caps/Caps.tsx | 2 +- .../caps/components/SharingDialog.tsx | 35 ++-- apps/web/app/(org)/dashboard/caps/page.tsx | 16 +- .../web/app/(org)/dashboard/dashboard-data.ts | 19 +- .../[id]/components/ClientMyCapsLink.tsx | 24 +-- apps/web/app/(org)/dashboard/refer/page.tsx | 2 +- .../dashboard/settings/account/Settings.tsx | 3 +- .../account/components/ProfileImage.tsx | 30 +-- .../components/OrganizationIcon.tsx | 8 +- .../dashboard/settings/organization/page.tsx | 16 +- .../[spaceId]/components/MembersIndicator.tsx | 4 +- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 6 +- .../(org)/dashboard/spaces/browse/page.tsx | 25 +-- apps/web/app/api/icon/route.ts | 42 +++++ apps/web/app/s/[videoId]/Share.tsx | 4 +- .../[videoId]/_components/CapVideoPlayer.tsx | 2 +- .../s/[videoId]/_components/ShareVideo.tsx | 3 +- .../app/s/[videoId]/_components/Toolbar.tsx | 4 +- .../_components/tabs/Activity/Comment.tsx | 2 +- .../_components/tabs/Activity/Comments.tsx | 4 +- apps/web/app/s/[videoId]/page.tsx | 19 +- apps/web/components/FileInput.tsx | 51 +++++- apps/web/components/forms/server.ts | 21 +-- apps/web/lib/folder.ts | 14 +- apps/web/lib/get-image-url.ts | 21 +++ .../database/migrations/meta/_journal.json | 171 +++++++++--------- packages/database/schema.ts | 6 +- packages/ui/src/components/Avatar.tsx | 2 +- .../web-backend/src/Users/UsersOnboarding.ts | 27 ++- 42 files changed, 568 insertions(+), 464 deletions(-) create mode 100644 apps/web/app/api/icon/route.ts create mode 100644 apps/web/lib/get-image-url.ts diff --git a/apps/web/actions/account/remove-profile-image.ts b/apps/web/actions/account/remove-profile-image.ts index 268bf7b023..2ca23e4cd9 100644 --- a/apps/web/actions/account/remove-profile-image.ts +++ b/apps/web/actions/account/remove-profile-image.ts @@ -3,8 +3,11 @@ 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 +16,37 @@ export async function removeProfileImage() { throw new Error("Unauthorized"); } - await db().update(users).set({ image: null }).where(eq(users.id, user.id)); + const imageUrlOrKey = user.imageUrlOrKey; + + // Delete the profile image from S3 if it exists + if (imageUrlOrKey) { + try { + // Extract the S3 key - handle both old URL format and new key format + let s3Key = imageUrlOrKey; + if (imageUrlOrKey.includes("amazonaws.com")) { + const url = new URL(imageUrlOrKey); + s3Key = url.pathname.substring(1); // Remove leading slash + } + + // 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({ imageUrlOrKey: 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..96a53f5f10 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,38 @@ 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.imageUrlOrKey; + const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`; try { const sanitizedFile = await sanitizeFile(file); - let imageUrl: string | undefined; + let imageUrlOrKey: 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.includes("amazonaws.com")) { + const url = new URL(oldImageUrlOrKey); + oldS3Key = url.pathname.substring(1); // Remove leading slash + } + + // 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 +83,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}`; - } + imageUrlOrKey = fileKey; }).pipe(runPromise); - if (typeof imageUrl !== "string" || imageUrl.length === 0) { - throw new Error("Failed to resolve uploaded profile image URL"); + if (!imageUrlOrKey) { + throw new Error("Failed to resolve uploaded profile image key"); } - const finalImageUrl = imageUrl; + const finalImageUrlOrKey = imageUrlOrKey; await db() .update(users) - .set({ image: finalImageUrl }) + .set({ imageUrlOrKey: finalImageUrlOrKey }) .where(eq(users.id, user.id)); revalidatePath("/dashboard/settings/account"); - revalidatePath("/dashboard", "layout"); - return { success: true, imageUrl: finalImageUrl } as const; + return { success: true, imageUrlOrKey: 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..172cb8f4ad 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"; @@ -17,7 +16,7 @@ interface CreateSpaceResponse { success: boolean; spaceId?: string; name?: string; - iconUrl?: string | null; + iconUrlOrKey?: string | null; error?: string; } @@ -66,7 +65,7 @@ export async function createSpace( const spaceId = Space.SpaceId.make(nanoId()); const iconFile = formData.get("icon") as File | null; - let iconUrl = null; + let iconUrlOrKey = null; if (iconFile) { // Validate file type @@ -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}`; - } + iconUrlOrKey = fileKey; }).pipe(runPromise); } catch (error) { console.error("Error uploading space icon:", error); @@ -131,8 +117,10 @@ export async function createSpace( name, organizationId: user.activeOrganizationId, createdById: user.id, - iconUrl, - description: iconUrl ? `Space with custom icon: ${iconUrl}` : null, + iconUrlOrKey, + description: iconUrlOrKey + ? `Space with custom icon: ${iconUrlOrKey}` + : null, createdAt: new Date(), updatedAt: new Date(), }); @@ -198,7 +186,7 @@ export async function createSpace( success: true, spaceId, name, - iconUrl, + iconUrlOrKey, }; } catch (error) { console.error("Error creating space:", error); diff --git a/apps/web/actions/organization/remove-icon.ts b/apps/web/actions/organization/remove-icon.ts index 6eba5818ce..f1a751e451 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,11 +32,36 @@ export async function removeOrganizationIcon( throw new Error("Only the owner can remove the organization icon"); } + const iconUrlOrKey = organization[0]?.iconUrlOrKey; + + // Delete the icon from S3 if it exists + if (iconUrlOrKey) { + try { + // Extract the S3 key - handle both old URL format and new key format + let s3Key = iconUrlOrKey; + if (iconUrlOrKey.includes("amazonaws.com")) { + const url = new URL(iconUrlOrKey); + s3Key = url.pathname.substring(1); // Remove leading slash + } + + // 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) .set({ - iconUrl: null, + iconUrlOrKey: null, }) .where(eq(organizations.id, organizationId)); diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index 0fe9743873..51e556afc1 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -48,11 +48,14 @@ export async function updateSpace(formData: FormData) { // Handle icon removal if requested if (formData.get("removeIcon") === "true") { - // Remove icon from S3 and set iconUrl to null + // Remove icon from S3 and set iconUrlOrKey to null 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]; + if (space?.iconUrlOrKey) { + // Extract the S3 key (it might already be a key or could be a legacy URL) + const key = space.iconUrlOrKey.startsWith("organizations/") + ? space.iconUrlOrKey + : space.iconUrlOrKey.match(/organizations\/.+/)?.[0]; if (key) { try { @@ -65,7 +68,10 @@ export async function updateSpace(formData: FormData) { } } } - await db().update(spaces).set({ iconUrl: null }).where(eq(spaces.id, id)); + await db() + .update(spaces) + .set({ iconUrlOrKey: null }) + .where(eq(spaces.id, id)); } else if (iconFile && iconFile.size > 0) { await uploadSpaceIcon(formData, id); } diff --git a/apps/web/actions/organization/upload-organization-icon.ts b/apps/web/actions/organization/upload-organization-icon.ts index 83c535e31f..93147846b6 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,47 +51,57 @@ 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]?.iconUrlOrKey; + // 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.includes("amazonaws.com")) { + const url = new URL(oldIconUrlOrKey); + oldS3Key = url.pathname.substring(1); // Remove leading slash + } + + // 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 iconUrlOrKey = fileKey; + await db() .update(organizations) - .set({ iconUrl }) + .set({ iconUrlOrKey }) .where(eq(organizations.id, organizationId)); revalidatePath("/dashboard/settings/organization"); - return { success: true, iconUrl }; + return { success: true, iconUrlOrKey }; } catch (error) { console.error("Error uploading organization icon:", error); throw new Error(error instanceof Error ? error.message : "Upload failed"); diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index 395d668026..3bdff94124 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -64,9 +64,11 @@ 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]; + if (space.iconUrlOrKey) { + // Extract the S3 key (it might already be a key or could be a legacy URL) + const key = space.iconUrlOrKey.startsWith("organizations/") + ? space.iconUrlOrKey + : space.iconUrlOrKey.match(/organizations\/.+/)?.[0]; if (key) { try { await bucket.deleteObject(key).pipe(runPromise); @@ -87,24 +89,15 @@ 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 iconUrlOrKey = fileKey; - // Update space with new icon URL - await db().update(spaces).set({ iconUrl }).where(eq(spaces.id, spaceId)); + await db() + .update(spaces) + .set({ iconUrlOrKey }) + .where(eq(spaces.id, spaceId)); revalidatePath("/dashboard"); - return { success: true, iconUrl }; + return { success: true, iconUrlOrKey }; } catch (error) { console.error("Error uploading space icon:", error); throw new Error(error instanceof Error ? error.message : "Upload failed"); diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 9f83404a9e..55def02bcb 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.imageUrlOrKey ?? 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..9a5555c7c9 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -5,7 +5,6 @@ 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 +15,7 @@ import { useRef, useState, } from "react"; +import { getImageUrl } from "@/lib/get-image-url"; import { useDashboardContext } from "../Contexts"; import { CapIcon, CogIcon, LayersIcon } from "./AnimatedIcons"; import { updateActiveOrganization } from "./Navbar/server"; @@ -76,22 +76,14 @@ 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 +137,20 @@ const OrgsMenu = ({ }} >
- {organization.organization.iconUrl ? ( -
- {organization.organization.name -
- ) : ( - - )} + } + />

{ )} >

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

void; } @@ -51,10 +53,9 @@ const SpaceDialog = ({ const formRef = useRef(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()}> @@ -117,7 +118,7 @@ export interface NewSpaceFormProps { id: string; name: string; members: string[]; - iconUrl?: string; + iconUrlOrKey?: string; } | null; } @@ -141,7 +142,7 @@ export const NewSpaceForm: React.FC = (props) => { mode: "onChange", }); - React.useEffect(() => { + useEffect(() => { if (space) { form.reset({ name: space.name, @@ -184,7 +185,7 @@ export const NewSpaceForm: React.FC = (props) => { if (edit && space?.id) { formData.append("id", space.id); // If the user removed the icon, send a removeIcon flag - if (selectedFile === null && space.iconUrl) { + if (selectedFile === null && space.iconUrlOrKey) { formData.append("removeIcon", "true"); } await updateSpace(formData); @@ -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: getImageUrl(m.user.imageUrlOrKey) ?? undefined, }))} onSelect={(selected) => field.onChange(selected.map((opt) => opt.value)) @@ -275,9 +276,9 @@ export const NewSpaceForm: React.FC = (props) => {
void }) => { space.primary ? "h-10" : "h-fit", )} > - {space.iconUrl ? ( - {space.name} - ) : ( - - )} + {!sidebarCollapsed && ( <> @@ -358,6 +349,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..478fc3599d 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -17,7 +17,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"; @@ -33,6 +32,7 @@ import { import { markAsRead } from "@/actions/notifications/mark-as-read"; import Notifications from "@/app/(org)/dashboard/_components/Notifications"; import { UpgradeModal } from "@/components/UpgradeModal"; +import { getImageUrl } from "@/lib/get-image-url"; import { useDashboardContext, useTheme } from "../../Contexts"; import { ArrowUpIcon, @@ -101,22 +101,14 @@ const Top = () => {
{activeSpace && Space}
- {activeSpace && - (activeSpace.iconUrl ? ( - {activeSpace?.name - ) : ( - - ))} + {activeSpace && ( + + )}

{title}

@@ -272,7 +264,7 @@ const User = () => { diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index bc0ff17e6b..a61f0bd3e9 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -40,7 +40,7 @@ export type VideoData = { sharedSpaces?: { id: string; name: string; - iconUrl: string; + iconUrlOrKey: string; isOrg: boolean; organizationId: string; }[]; diff --git a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx index b3d7c0ca57..442c9894fb 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -16,13 +16,13 @@ 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 { Tooltip } from "@/components/Tooltip"; +import { getImageUrl } from "@/lib/get-image-url"; interface SharingDialogProps { isOpen: boolean; @@ -32,7 +32,7 @@ interface SharingDialogProps { sharedSpaces: { id: string; name: string; - iconUrl?: string | null; + iconUrlOrKey?: string | null; organizationId: string; }[]; onSharingUpdated: (updatedSharedSpaces: string[]) => void; @@ -262,9 +262,9 @@ export const SharingDialog: React.FC = ({ {activeTab === "Share" ? ( <> {/* Public sharing toggle */} -
-
-
+
+
+
@@ -388,7 +388,7 @@ const SpaceCard = ({ space: { id: string; name: string; - iconUrl?: string | null; + iconUrlOrKey?: string | null; organizationId: string; }; selectedSpaces: Set; @@ -416,23 +416,12 @@ const SpaceCard = ({ )} onClick={() => handleToggleSpace(space.id)} > - {space.iconUrl ? ( -
- {space.name} -
- ) : ( - - )} +

{space.name}

diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index f86da3da91..fdd301fc0e 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -33,7 +33,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: spaces.id, name: spaces.name, organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, + iconUrlOrKey: organizations.iconUrlOrKey, }) .from(spaceVideos) .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) @@ -47,7 +47,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: organizations.id, name: organizations.name, organizationId: organizations.id, - iconUrl: organizations.iconUrl, + iconUrlOrKey: organizations.iconUrlOrKey, }) .from(sharedVideos) .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) @@ -60,7 +60,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: string; name: string; organizationId: string; - iconUrl: string; + iconUrlOrKey: string; isOrg: boolean; }> > = {}; @@ -74,7 +74,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: space.id, name: space.name, organizationId: space.organizationId, - iconUrl: space.iconUrl || "", + iconUrlOrKey: space.iconUrlOrKey || "", isOrg: false, }); }); @@ -88,7 +88,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: org.id, name: org.name, organizationId: org.organizationId, - iconUrl: org.iconUrl || "", + iconUrlOrKey: org.iconUrlOrKey || "", isOrg: true, }); }); @@ -134,13 +134,15 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { public: videos.public, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, - sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>` + sharedOrganizations: sql< + { id: string; name: string; iconUrlOrKey: string }[] + >` COALESCE( JSON_ARRAYAGG( JSON_OBJECT( 'id', ${organizations.id}, 'name', ${organizations.name}, - 'iconUrl', ${organizations.iconUrl} + 'iconUrlOrKey', ${organizations.iconUrlOrKey} ) ), JSON_ARRAY() diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index 0f7655c6d9..e6e457bafe 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -18,7 +18,7 @@ export type Organization = { members: (typeof organizationMembers.$inferSelect & { user: Pick< typeof users.$inferSelect, - "id" | "name" | "email" | "lastName" | "image" + "id" | "name" | "email" | "lastName" | "imageUrlOrKey" >; })[]; invites: (typeof organizationInvites.$inferSelect)[]; @@ -47,13 +47,14 @@ export async function getDashboardData(user: typeof userSelectProps) { organization: organizations, settings: organizations.settings, member: organizationMembers, + iconUrlOrKey: organizations.iconUrlOrKey, user: { id: users.id, name: users.name, lastName: users.lastName, email: users.email, inviteQuota: users.inviteQuota, - image: users.image, + imageUrlOrKey: users.imageUrlOrKey, defaultOrgId: users.defaultOrgId, }, }) @@ -129,13 +130,13 @@ export async function getDashboardData(user: typeof userSelectProps) { description: spaces.description, organizationId: spaces.organizationId, createdById: spaces.createdById, - iconUrl: spaces.iconUrl, + iconUrlOrKey: spaces.iconUrlOrKey, 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)) @@ -203,7 +204,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, + iconUrlOrKey: activeOrgInfo.organization.iconUrlOrKey, memberCount: orgMemberCount, createdById: activeOrgInfo.organization.ownerId, videoCount: orgVideoCount, @@ -240,7 +241,7 @@ export async function getDashboardData(user: typeof userSelectProps) { name: users.name, lastName: users.lastName, email: users.email, - image: users.image, + imageUrlOrKey: users.imageUrlOrKey, }, }) .from(organizationMembers) 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..7775fd1a7c 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 { getImageUrl } from "@/lib/get-image-url"; import { useDashboardContext } from "../../../Contexts"; import { registerDropTarget } from "./ClientCapCard"; @@ -124,23 +124,13 @@ 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/refer/page.tsx b/apps/web/app/(org)/dashboard/refer/page.tsx index 4d953784ad..16c5bf7e16 100644 --- a/apps/web/app/(org)/dashboard/refer/page.tsx +++ b/apps/web/app/(org)/dashboard/refer/page.tsx @@ -54,7 +54,7 @@ export default async function ReferPage() { user.id, user.name, user.email, - user.image, + user.imageUrlOrKey, ); return ; diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 114e02d5a3..63d5103bda 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -35,7 +35,7 @@ export const Settings = ({ const firstNameId = useId(); const lastNameId = useId(); const contactEmailId = useId(); - const initialProfileImage = user?.image ?? null; + const initialProfileImage = user?.imageUrlOrKey ?? null; const [profileImageOverride, setProfileImageOverride] = useState< string | null | undefined >(undefined); @@ -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..13506f6d57 100644 --- a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button } from "@cap/ui"; +import { Avatar, Button } from "@cap/ui"; import { faImage, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -8,6 +8,7 @@ import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Tooltip } from "@/components/Tooltip"; +import { getImageUrl } from "@/lib/get-image-url"; interface ProfileImageProps { initialPreviewUrl?: string | null; @@ -16,6 +17,7 @@ interface ProfileImageProps { disabled?: boolean; isUploading?: boolean; isRemoving?: boolean; + userName?: string | null; } export function ProfileImage({ @@ -25,16 +27,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 +51,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 = ""; } @@ -79,17 +89,11 @@ export function ProfileImage({ previewUrl ? "border-solid" : "border-dashed", )} > - {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.iconUrlOrKey ?? 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( @@ -47,6 +50,7 @@ export const OrganizationIcon = () => { if (result.success) { toast.success("Organization icon removed successfully"); + router.refresh(); } } catch (error) { console.error("Error removing organization icon:", error); @@ -72,7 +76,7 @@ export const OrganizationIcon = () => { 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..aaf41eb5a0 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/page.tsx @@ -1,6 +1,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { organizationMembers, organizations } from "@cap/database/schema"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; import { and, eq } from "drizzle-orm"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; @@ -20,9 +24,12 @@ export default async function OrganizationPage() { const [member] = await db() .select({ role: organizationMembers.role, + ownerId: organizations.ownerId, + userId: users.id, }) .from(organizationMembers) .limit(1) + .leftJoin(users, eq(organizationMembers.userId, users.id)) .leftJoin( organizations, eq(organizationMembers.organizationId, organizations.id), @@ -34,9 +41,10 @@ export default async function OrganizationPage() { ), ); - if (member?.role !== "owner") { - redirect("/dashboard/caps"); - } + if (member) + if (member.role !== "owner") { + redirect("/dashboard/caps"); + } return ; } 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..95ba8832e3 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx @@ -99,7 +99,7 @@ export const MembersIndicator = ({ .map((m) => ({ value: m.userId, label: m.name || m.email, - image: m.image || undefined, + image: m.imageUrlOrKey || undefined, })); }, [organizationMembers, user], @@ -167,7 +167,7 @@ export const MembersIndicator = ({ > diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 750d527067..678b0cf5f1 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -29,7 +29,7 @@ export type SpaceMemberData = { id: string; userId: string; role: string; - image?: string | null; + imageUrlOrKey?: string | null; name: string | null; email: string; }; @@ -63,7 +63,7 @@ async function fetchSpaceMembers(spaceId: Space.SpaceIdOrOrganisationId) { role: sql`'member'`, name: users.name, email: users.email, - image: users.image, + imageUrlOrKey: users.imageUrlOrKey, }) .from(spaceMembers) .innerJoin(users, eq(spaceMembers.userId, users.id)) @@ -78,7 +78,7 @@ async function fetchOrganizationMembers(orgId: Organisation.OrganisationId) { role: organizationMembers.role, name: users.name, email: users.email, - image: users.image, + imageUrlOrKey: users.imageUrlOrKey, }) .from(organizationMembers) .innerJoin(users, eq(organizationMembers.userId, users.id)) diff --git a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx index caf8debbcb..a5d29a24e7 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 { getImageUrl } from "@/lib/get-image-url"; import { ConfirmationDialog } from "../../_components/ConfirmationDialog"; import SpaceDialog from "../../_components/Navbar/SpaceDialog"; import { useDashboardContext } from "../../Contexts"; @@ -132,21 +132,12 @@ export default function BrowseSpacesPage() { className="border-t transition-colors cursor-pointer hover:bg-gray-2 border-gray-3" > - {space.iconUrl ? ( - {space.name} - ) : ( - - )} + {space.name} @@ -177,7 +168,7 @@ export default function BrowseSpacesPage() { members: (trueActiveOrgMembers || []).map( (m: { user: { id: string } }) => m.user.id, ), - iconUrl: space.iconUrl, + iconUrlOrKey: space.iconUrlOrKey, }); setShowSpaceDialog(true); }} diff --git a/apps/web/app/api/icon/route.ts b/apps/web/app/api/icon/route.ts new file mode 100644 index 0000000000..ba3a2448b2 --- /dev/null +++ b/apps/web/app/api/icon/route.ts @@ -0,0 +1,42 @@ +import { S3Buckets } from "@cap/web-backend"; +import { Effect, Option } from "effect"; +import { type NextRequest, NextResponse } from "next/server"; +import { runPromise } from "@/lib/server"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = request.nextUrl; + const key = searchParams.get("key"); + + if (!key) { + return NextResponse.json( + { error: "Missing key parameter" }, + { status: 400 }, + ); + } + + // Validate that the key looks like an organization/space/user icon path + if (!key.startsWith("organizations/") && !key.startsWith("users/")) { + return NextResponse.json( + { error: "Invalid key format" }, + { status: 400 }, + ); + } + + const signedUrl = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); + return yield* bucket.getSignedObjectUrl(key); + }).pipe(runPromise); + + return NextResponse.redirect(signedUrl); + } catch (error) { + console.error("Error generating signed URL for icon:", error); + return NextResponse.json( + { + error: "Failed to generate signed URL", + details: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 63dd9a922f..441c79a526 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -25,12 +25,12 @@ import { Toolbar } from "./_components/Toolbar"; type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; - authorImage: string | null; + authorImageUrlOrKey: string | null; }; export type CommentType = typeof commentsSchema.$inferSelect & { authorName?: string | null; - authorImage?: string | null; + authorImageUrlOrKey?: string | null; sending?: boolean; }; diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index a346cc8fcd..ee7fd7efa1 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -50,7 +50,7 @@ interface Props { type: "text" | "emoji"; content: string; authorName?: string | null; - authorImage?: string | null; + authorImageUrlOrKey?: string | null; }>; onSeek?: (time: number) => void; } diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 6f006c6581..e21ab3e92b 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -29,7 +29,7 @@ declare global { type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; - authorImage: string | null; + authorImageUrlOrKey: string | null; }; export const ShareVideo = forwardRef< @@ -203,6 +203,7 @@ export const ShareVideo = forwardRef< timestamp: comment.timestamp, content: comment.content, authorName: comment.authorName, + authorImageUrlOrKey: comment.authorImageUrlOrKey ?? undefined, }))} onSeek={handleSeek} /> diff --git a/apps/web/app/s/[videoId]/_components/Toolbar.tsx b/apps/web/app/s/[videoId]/_components/Toolbar.tsx index 9fe6d31255..ae88613021 100644 --- a/apps/web/app/s/[videoId]/_components/Toolbar.tsx +++ b/apps/web/app/s/[videoId]/_components/Toolbar.tsx @@ -40,7 +40,7 @@ export const Toolbar = ({ id: Comment.CommentId.make(`temp-${Date.now()}`), authorId: User.UserId.make(user?.id || "anonymous"), authorName: user?.name || "Anonymous", - authorImage: user?.image ?? null, + authorImageUrlOrKey: user?.imageUrlOrKey ?? null, content: emoji, createdAt: new Date(), videoId: data.id, @@ -82,7 +82,7 @@ export const Toolbar = ({ id: Comment.CommentId.make(`temp-${Date.now()}`), authorId: User.UserId.make(user?.id || "anonymous"), authorName: user?.name || "Anonymous", - authorImage: user?.image ?? null, + authorImageUrlOrKey: user?.imageUrlOrKey ?? null, content: comment, createdAt: new Date(), videoId: data.id, diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx index e856f48e58..348119d302 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comment.tsx @@ -78,7 +78,7 @@ const CommentComponent: React.FC<{ className="size-6" letterClass="text-sm" name={comment.authorName} - imageUrl={comment.authorImage ?? undefined} + imageUrl={comment.authorImageUrlOrKey ?? undefined} /> = []; // Add space-level sharing @@ -83,7 +83,7 @@ async function getSharedSpacesForVideo(videoId: Video.VideoId) { id: space.id, name: space.name, organizationId: space.organizationId, - iconUrl: space.iconUrl || undefined, + iconUrlOrKey: space.iconUrlOrKey || undefined, }); }); @@ -93,7 +93,7 @@ async function getSharedSpacesForVideo(videoId: Video.VideoId) { id: org.id, name: org.name, organizationId: org.organizationId, - iconUrl: org.iconUrl || undefined, + iconUrlOrKey: org.iconUrlOrKey || undefined, }); }); @@ -271,7 +271,8 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { name: videos.name, ownerId: videos.ownerId, ownerName: users.name, - ownerImage: users.image, + ownerImage: users.imageUrlOrKey, + ownerImageUrlOrKey: users.imageUrlOrKey, orgId: videos.orgId, createdAt: videos.createdAt, updatedAt: videos.updatedAt, @@ -366,7 +367,7 @@ async function AuthorizedContent({ hasPassword: boolean; ownerIsPro?: boolean; ownerName?: string | null; - ownerImage?: string | null; + ownerImageUrlOrKey?: string | null; orgSettings?: OrganizationSettings | null; videoSettings?: OrganizationSettings | null; }; @@ -471,7 +472,7 @@ async function AuthorizedContent({ name: videos.name, ownerId: videos.ownerId, ownerName: users.name, - ownerImage: users.image, + ownerImageUrlOrKey: users.imageUrlOrKey, ownerIsPro: sql`${users.stripeSubscriptionStatus} IN ('active','trialing','complete','paid') OR ${users.thirdPartyStripeSubscriptionId} IS NOT NULL`.mapWith( Boolean, @@ -661,7 +662,7 @@ async function AuthorizedContent({ updatedAt: comments.updatedAt, parentCommentId: comments.parentCommentId, authorName: users.name, - authorImage: users.image, + authorImageUrlOrKey: users.imageUrlOrKey, }) .from(comments) .leftJoin(users, eq(comments.authorId, users.id)) diff --git a/apps/web/components/FileInput.tsx b/apps/web/components/FileInput.tsx index ecac31dce4..6863f46d7d 100644 --- a/apps/web/components/FileInput.tsx +++ b/apps/web/components/FileInput.tsx @@ -12,6 +12,7 @@ import Image from "next/image"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { getImageUrl } from "@/lib/get-image-url"; import { Tooltip } from "./Tooltip"; const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]); @@ -54,20 +55,36 @@ export const FileInput: React.FC = ({ const [previewUrl, setPreviewUrl] = useState( initialPreviewUrl, ); + const [isLocalPreview, setIsLocalPreview] = useState(false); + const previousPreviewRef = useRef<{ + url: string | null; + isLocal: boolean; + }>({ url: null, isLocal: false }); // Update preview URL when initialPreviewUrl changes useEffect(() => { + // Clean up old blob URL if it exists + if (previousPreviewRef.current.url && previousPreviewRef.current.isLocal) { + URL.revokeObjectURL(previousPreviewRef.current.url); + } + setPreviewUrl(initialPreviewUrl); + setIsLocalPreview(false); + + previousPreviewRef.current = { + url: initialPreviewUrl, + isLocal: false, + }; }, [initialPreviewUrl]); // Clean up the preview URL when component unmounts useEffect(() => { return () => { - if (previewUrl && previewUrl !== initialPreviewUrl) { + if (previewUrl && isLocalPreview) { URL.revokeObjectURL(previewUrl); } }; - }, [previewUrl, initialPreviewUrl]); + }, [previewUrl, isLocalPreview]); const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); @@ -144,14 +161,20 @@ export const FileInput: React.FC = ({ return; } - // Clean up previous preview URL if it's not the initial preview URL - if (previewUrl && previewUrl !== initialPreviewUrl) { + // Clean up previous preview URL if it's a local blob URL + if (previewUrl && isLocalPreview) { URL.revokeObjectURL(previewUrl); } // Create a new preview URL for immediate feedback const newPreviewUrl = URL.createObjectURL(file); setPreviewUrl(newPreviewUrl); + setIsLocalPreview(true); + + previousPreviewRef.current = { + url: newPreviewUrl, + isLocal: true, + }; // Call the onChange callback if (onChange) { @@ -163,12 +186,18 @@ export const FileInput: React.FC = ({ const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); - // Clean up preview URL if it's not the initial preview URL - if (previewUrl && previewUrl !== initialPreviewUrl) { + // Clean up preview URL if it's a local blob URL + if (previewUrl && isLocalPreview) { URL.revokeObjectURL(previewUrl); } setPreviewUrl(null); + setIsLocalPreview(false); + + previousPreviewRef.current = { + url: null, + isLocal: false, + }; if (fileInputRef.current) { fileInputRef.current.value = ""; @@ -210,12 +239,14 @@ export const FileInput: React.FC = ({ className="flex overflow-hidden relative flex-shrink-0 justify-center items-center rounded-full" > {previewUrl && ( - File preview )}
diff --git a/apps/web/components/forms/server.ts b/apps/web/components/forms/server.ts index ca96a87dfb..f9e36236df 100644 --- a/apps/web/components/forms/server.ts +++ b/apps/web/components/forms/server.ts @@ -42,7 +42,7 @@ export async function createOrganization(formData: FormData) { id: Organisation.OrganisationId; ownerId: User.UserId; name: string; - iconUrl?: string; + iconUrlOrKey?: string; } = { id: organizationId, ownerId: user.id, @@ -67,8 +67,6 @@ export async function createOrganization(formData: FormData) { const fileKey = `organizations/${organizationId}/icon-${Date.now()}.${fileExtension}`; try { - let iconUrl: string | undefined; - await Effect.gen(function* () { const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); @@ -77,24 +75,9 @@ export async function createOrganization(formData: FormData) { 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}`; - } }).pipe(runPromise); - // Add the icon URL to the organization values - orgValues.iconUrl = iconUrl; + orgValues.iconUrlOrKey = fileKey; } catch (error) { console.error("Error uploading organization icon:", error); throw new Error(error instanceof Error ? error.message : "Upload failed"); diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index 35ec3fcc2f..6175e59d72 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -76,7 +76,7 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: spaces.id, name: spaces.name, organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, + iconUrlOrKey: organizations.iconUrlOrKey, }) .from(spaceVideos) .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) @@ -97,7 +97,7 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: organizations.id, name: organizations.name, organizationId: organizations.id, - iconUrl: organizations.iconUrl, + iconUrlOrKey: organizations.iconUrlOrKey, }) .from(sharedVideos) .innerJoin( @@ -119,7 +119,7 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: string; name: string; organizationId: string; - iconUrl: string; + iconUrlOrKey: string; isOrg: boolean; }> > = {}; @@ -132,7 +132,7 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: space.id, name: space.name, organizationId: space.organizationId, - iconUrl: space.iconUrl || "", + iconUrlOrKey: space.iconUrlOrKey || "", isOrg: false, }); }); @@ -146,7 +146,7 @@ const getSharedSpacesForVideos = Effect.fn(function* ( id: org.id, name: org.name, organizationId: org.organizationId, - iconUrl: org.iconUrl || "", + iconUrlOrKey: org.iconUrlOrKey || "", isOrg: true, }); }); @@ -177,14 +177,14 @@ export const getVideosByFolderId = Effect.fn(function* ( totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, sharedOrganizations: sql< - { id: string; name: string; iconUrl: string }[] + { id: string; name: string; iconUrlOrKey: string }[] >` COALESCE( JSON_ARRAYAGG( JSON_OBJECT( 'id', ${organizations.id}, 'name', ${organizations.name}, - 'iconUrl', ${organizations.iconUrl} + 'iconUrlOrKey', ${organizations.iconUrlOrKey} ) ), JSON_ARRAY() diff --git a/apps/web/lib/get-image-url.ts b/apps/web/lib/get-image-url.ts new file mode 100644 index 0000000000..60fcb202f5 --- /dev/null +++ b/apps/web/lib/get-image-url.ts @@ -0,0 +1,21 @@ +/** + * Helper to convert an imageKey (S3 key) or legacy URL to a usable URL + * @param imageKeyOrUrl - Can be an S3 key (starts with "users/" or "organizations/") or a legacy URL + * @returns A URL that can be used in img tags or Next.js Image components + */ +export function getImageUrl( + imageKeyOrUrl: string | null | undefined, +): string | null { + if (!imageKeyOrUrl) return null; + // + // If it's an S3 key (starts with users/ or organizations/), convert to API route + if ( + imageKeyOrUrl.startsWith("users/") || + imageKeyOrUrl.startsWith("organizations/") + ) { + return `/api/icon?key=${encodeURIComponent(imageKeyOrUrl)}`; + } + + // Otherwise, return as-is (legacy URL or external URL) + return imageKeyOrUrl; +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index d06d963630..0cd87aaad4 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -1,83 +1,90 @@ { - "version": "5", - "dialect": "mysql", - "entries": [ - { - "idx": 0, - "version": "5", - "when": 1743020179593, - "tag": "0000_brown_sunfire", - "breakpoints": true - }, - { - "idx": 1, - "version": "5", - "when": 1749268354138, - "tag": "0001_white_young_avengers", - "breakpoints": true - }, - { - "idx": 2, - "version": "5", - "when": 1750935538683, - "tag": "0002_dusty_maginty", - "breakpoints": true - }, - { - "idx": 3, - "version": "5", - "when": 1751274435418, - "tag": "0003_outstanding_kylun", - "breakpoints": true - }, - { - "idx": 4, - "version": "5", - "when": 1751299325634, - "tag": "0004_optimal_eddie_brock", - "breakpoints": true - }, - { - "idx": 5, - "version": "5", - "when": 1751979972128, - "tag": "0005_graceful_fenris", - "breakpoints": true - }, - { - "idx": 6, - "version": "5", - "when": 1751982995648, - "tag": "0006_woozy_jamie_braddock", - "breakpoints": true - }, - { - "idx": 7, - "version": "5", - "when": 1754314124918, - "tag": "0007_cheerful_rocket_raccoon", - "breakpoints": true - }, - { - "idx": 8, - "version": "5", - "when": 1759139970377, - "tag": "0008_condemned_gamora", - "breakpoints": true - }, - { - "idx": 9, - "version": "5", - "when": 1759993551600, - "tag": "0009_sad_carmella_unuscione", - "breakpoints": true - }, - { - "idx": 10, - "version": "5", - "when": 1760343098514, - "tag": "0010_unusual_leo", - "breakpoints": true - } - ] -} + "version": "5", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1743020179593, + "tag": "0000_brown_sunfire", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1749268354138, + "tag": "0001_white_young_avengers", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1750935538683, + "tag": "0002_dusty_maginty", + "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1751274435418, + "tag": "0003_outstanding_kylun", + "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1751299325634, + "tag": "0004_optimal_eddie_brock", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1751979972128, + "tag": "0005_graceful_fenris", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1751982995648, + "tag": "0006_woozy_jamie_braddock", + "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1754314124918, + "tag": "0007_cheerful_rocket_raccoon", + "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1759139970377, + "tag": "0008_condemned_gamora", + "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1759993551600, + "tag": "0009_sad_carmella_unuscione", + "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1760343098514, + "tag": "0010_unusual_leo", + "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1760951331364, + "tag": "0011_careless_apocalypse", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/database/schema.ts b/packages/database/schema.ts index c7711c51bf..978507dc53 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -63,7 +63,7 @@ export const users = mysqlTable( lastName: varchar("lastName", { length: 255 }), email: varchar("email", { length: 255 }).unique().notNull(), emailVerified: timestamp("emailVerified"), - image: varchar("image", { length: 255 }), + imageUrlOrKey: varchar("imageUrlOrKey", { length: 255 }), stripeCustomerId: varchar("stripeCustomerId", { length: 255 }), stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255, @@ -189,7 +189,7 @@ export const organizations = mysqlTable( disableTranscript?: boolean; disableComments?: boolean; }>(), - iconUrl: varchar("iconUrl", { length: 1024 }), + iconUrlOrKey: varchar("iconUrlOrKey", { length: 1024 }), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), workosOrganizationId: varchar("workosOrganizationId", { length: 255 }), @@ -598,7 +598,7 @@ export const spaces = mysqlTable( .notNull() .$type(), createdById: nanoId("createdById").notNull().$type(), - iconUrl: varchar("iconUrl", { length: 255 }), + iconUrlOrKey: varchar("iconUrlOrKey", { length: 255 }), description: varchar("description", { length: 1000 }), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), diff --git a/packages/ui/src/components/Avatar.tsx b/packages/ui/src/components/Avatar.tsx index 2bac164503..cf5a53ca97 100644 --- a/packages/ui/src/components/Avatar.tsx +++ b/packages/ui/src/components/Avatar.tsx @@ -80,7 +80,7 @@ export const Avatar: React.FC = ({ {name diff --git a/packages/web-backend/src/Users/UsersOnboarding.ts b/packages/web-backend/src/Users/UsersOnboarding.ts index 272ce88857..1972dd69d9 100644 --- a/packages/web-backend/src/Users/UsersOnboarding.ts +++ b/packages/web-backend/src/Users/UsersOnboarding.ts @@ -181,12 +181,11 @@ export class UsersOnboarding extends Effect.Service()( ); yield* bucket.putObject(fileKey, fileData, { contentType }); - const iconUrl = yield* bucket.getSignedObjectUrl(fileKey); yield* db.use((db) => db .update(Db.organizations) - .set({ iconUrl }) + .set({ iconUrlOrKey: fileKey }) .where(Dz.eq(Db.organizations.id, finalOrganizationId)), ); }).pipe( @@ -288,17 +287,25 @@ export class UsersOnboarding extends Effect.Service()( ); if (!existingOrg || !user.onboardingSteps?.organizationSetup) { + const newOrgId = Organisation.OrganisationId.make(nanoId()); + await tx.insert(Db.organizations).values({ + id: newOrgId, + name: orgName, + ownerId: currentUser.id, + }); + await tx.insert(Db.organizationMembers).values({ + id: nanoId(), + organizationId: newOrgId, + userId: currentUser.id, + role: "owner", + }); await tx - .update(Db.organizations) + .update(Db.users) .set({ - name: orgName, + activeOrganizationId: newOrgId, + defaultOrgId: newOrgId, }) - .where( - Dz.eq( - Db.organizations.id, - currentUser.activeOrganizationId, - ), - ); + .where(Dz.eq(Db.users.id, currentUser.id)); } }), ); From b33a7aefe5208f7952798a82d277f696290f8d4f Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:12:53 +0300 Subject: [PATCH 2/9] move to rpc --- .../(org)/dashboard/_components/MobileTab.tsx | 29 +++------- .../dashboard/_components/Navbar/Items.tsx | 27 ++++----- .../_components/Navbar/SpaceDialog.tsx | 4 +- .../_components/Navbar/SpacesList.tsx | 10 ++-- .../dashboard/_components/Navbar/Top.tsx | 15 +++-- .../_components/Notifications/Skeleton.tsx | 2 +- .../caps/components/SharingDialog.tsx | 8 +-- apps/web/app/(org)/dashboard/caps/loading.tsx | 4 +- .../[id]/components/ClientMyCapsLink.tsx | 8 +-- .../account/components/ProfileImage.tsx | 11 ++-- .../dashboard/spaces/[spaceId]/loading.tsx | 4 +- .../(org)/dashboard/spaces/browse/loading.tsx | 2 +- .../(org)/dashboard/spaces/browse/page.tsx | 8 +-- apps/web/app/api/icon/route.ts | 42 -------------- .../s/[videoId]/_components/ShareHeader.tsx | 9 +-- apps/web/components/FileInput.tsx | 10 +++- apps/web/components/SignedImageUrl.tsx | 56 +++++++++++++++++++ apps/web/components/Tooltip.tsx | 2 +- apps/web/lib/get-image-url.ts | 24 +++++--- apps/web/lib/use-signed-image-url.ts | 28 ++++++++++ packages/web-backend/src/Users/UsersRpcs.ts | 26 ++++++++- packages/web-domain/src/User.ts | 13 +++++ 22 files changed, 210 insertions(+), 132 deletions(-) delete mode 100644 apps/web/app/api/icon/route.ts create mode 100644 apps/web/components/SignedImageUrl.tsx create mode 100644 apps/web/lib/use-signed-image-url.ts diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index 9a5555c7c9..826ce3ba27 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -1,6 +1,5 @@ "use client"; -import { Avatar } from "@cap/ui"; import { useClickAway } from "@uidotdev/usehooks"; import clsx from "clsx"; import { Check, ChevronDown } from "lucide-react"; @@ -15,7 +14,7 @@ import { useRef, useState, } from "react"; -import { getImageUrl } from "@/lib/get-image-url"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../Contexts"; import { CapIcon, CogIcon, LayersIcon } from "./AnimatedIcons"; import { updateActiveOrganization } from "./Navbar/server"; @@ -76,13 +75,11 @@ const Orgs = ({ ref={containerRef} className="flex gap-1.5 items-center p-2 rounded-full border bg-gray-3 border-gray-5" > -

{activeOrg?.organization.name} @@ -137,19 +134,11 @@ const OrgsMenu = ({ }} >

- - } +

{ )} >

- { "relative flex-shrink-0 mx-auto", sidebarCollapsed ? "size-6" : "size-7", )} - name={ - activeOrg?.organization.name ?? "No organization found" - } - imageUrl={ - getImageUrl(activeOrg?.organization.iconUrlOrKey) ?? - undefined - } />
@@ -215,15 +212,13 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { }} >
-

= (props) => { .map((m) => ({ value: m.user.id, label: m.user.name || m.user.email, - image: getImageUrl(m.user.imageUrlOrKey) ?? undefined, + image: m.user.imageUrlOrKey ?? undefined, }))} onSelect={(selected) => field.onChange(selected.map((opt) => opt.value)) diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx index d81c1bc5e4..4a23dec944 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpacesList.tsx @@ -1,6 +1,6 @@ "use client"; -import { Avatar, Button } from "@cap/ui"; +import { Button } from "@cap/ui"; import type { Space } from "@cap/web-domain"; import { faLayerGroup, @@ -17,8 +17,8 @@ import { useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { shareCap } from "@/actions/caps/share"; import { deleteSpace } from "@/actions/organization/delete-space"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; -import { getImageUrl } from "@/lib/get-image-url"; import { useDashboardContext } from "../../Contexts"; import type { Spaces } from "../../dashboard-data"; import { LayersIcon } from "../AnimatedIcons"; @@ -279,7 +279,9 @@ const SpacesList = ({ toggleMobileNav }: { toggleMobileNav?: () => void }) => { space.primary ? "h-10" : "h-fit", )} > - void }) => { "relative flex-shrink-0", sidebarCollapsed ? "size-6" : "size-5", )} - name={space.name} - imageUrl={getImageUrl(space.iconUrlOrKey) ?? undefined} /> {!sidebarCollapsed && ( <> diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 478fc3599d..0156c3f835 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, @@ -31,8 +30,8 @@ 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 { getImageUrl } from "@/lib/get-image-url"; import { useDashboardContext, useTheme } from "../../Contexts"; import { ArrowUpIcon, @@ -102,11 +101,11 @@ const Top = () => { {activeSpace && Space}

{activeSpace && ( - )}

@@ -261,10 +260,10 @@ 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 442c9894fb..816d161e27 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -21,8 +21,8 @@ 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"; -import { getImageUrl } from "@/lib/get-image-url"; interface SharingDialogProps { isOpen: boolean; @@ -416,11 +416,11 @@ const SpaceCard = ({ )} onClick={() => handleToggleSpace(space.id)} > -

{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/folder/[id]/components/ClientMyCapsLink.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx index 7775fd1a7c..ccdd033012 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx @@ -8,7 +8,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; -import { getImageUrl } from "@/lib/get-image-url"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../../Contexts"; import { registerDropTarget } from "./ClientCapCard"; @@ -125,11 +125,11 @@ export function ClientMyCapsLink({ onDrop={handleDrop} > {activeSpace && ( - )} {activeSpace ? activeSpace.name : "My Caps"} 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 13506f6d57..628b3638f0 100644 --- a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx @@ -1,14 +1,13 @@ "use client"; -import { Avatar, Button } from "@cap/ui"; -import { faImage, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { Button } from "@cap/ui"; +import { 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"; -import { getImageUrl } from "@/lib/get-image-url"; interface ProfileImageProps { initialPreviewUrl?: string | null; @@ -89,9 +88,9 @@ export function ProfileImage({ previewUrl ? "border-solid" : "border-dashed", )} > -
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 a5d29a24e7..f2e30f7f69 100644 --- a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx @@ -14,7 +14,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { deleteSpace } from "@/actions/organization/delete-space"; -import { getImageUrl } from "@/lib/get-image-url"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { ConfirmationDialog } from "../../_components/ConfirmationDialog"; import SpaceDialog from "../../_components/Navbar/SpaceDialog"; import { useDashboardContext } from "../../Contexts"; @@ -132,11 +132,11 @@ export default function BrowseSpacesPage() { className="border-t transition-colors cursor-pointer hover:bg-gray-2 border-gray-3" > - {space.name} diff --git a/apps/web/app/api/icon/route.ts b/apps/web/app/api/icon/route.ts deleted file mode 100644 index ba3a2448b2..0000000000 --- a/apps/web/app/api/icon/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { S3Buckets } from "@cap/web-backend"; -import { Effect, Option } from "effect"; -import { type NextRequest, NextResponse } from "next/server"; -import { runPromise } from "@/lib/server"; - -export async function GET(request: NextRequest) { - try { - const { searchParams } = request.nextUrl; - const key = searchParams.get("key"); - - if (!key) { - return NextResponse.json( - { error: "Missing key parameter" }, - { status: 400 }, - ); - } - - // Validate that the key looks like an organization/space/user icon path - if (!key.startsWith("organizations/") && !key.startsWith("users/")) { - return NextResponse.json( - { error: "Invalid key format" }, - { status: 400 }, - ); - } - - const signedUrl = await Effect.gen(function* () { - const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); - return yield* bucket.getSignedObjectUrl(key); - }).pipe(runPromise); - - return NextResponse.redirect(signedUrl); - } catch (error) { - console.error("Error generating signed URL for icon:", error); - return NextResponse.json( - { - error: "Failed to generate signed URL", - details: error instanceof Error ? error.message : "Unknown error", - }, - { status: 500 }, - ); - } -} diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 1afab5a437..a0ce3d8a0e 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -3,7 +3,7 @@ import type { userSelectProps } from "@cap/database/auth/session"; import type { videos } from "@cap/database/schema"; import { buildEnv, NODE_ENV } from "@cap/env"; -import { Avatar, Button } from "@cap/ui"; +import { Button } from "@cap/ui"; import { userIsPro } from "@cap/utils"; import { faChevronDown, faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -16,6 +16,7 @@ import { editTitle } from "@/actions/videos/edit-title"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { SharingDialog } from "@/app/(org)/dashboard/caps/components/SharingDialog"; import type { Spaces } from "@/app/(org)/dashboard/dashboard-data"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; import { UpgradeModal } from "@/components/UpgradeModal"; import { usePublicEnv } from "@/utils/public-env"; @@ -235,9 +236,9 @@ export const ShareHeader = ({
- diff --git a/apps/web/components/FileInput.tsx b/apps/web/components/FileInput.tsx index 6863f46d7d..a44ab56c26 100644 --- a/apps/web/components/FileInput.tsx +++ b/apps/web/components/FileInput.tsx @@ -12,7 +12,8 @@ import Image from "next/image"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { getImageUrl } from "@/lib/get-image-url"; +import { isS3Key } from "@/lib/get-image-url"; +import { useSignedImageUrl } from "@/lib/use-signed-image-url"; import { Tooltip } from "./Tooltip"; const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]); @@ -56,6 +57,9 @@ export const FileInput: React.FC = ({ initialPreviewUrl, ); const [isLocalPreview, setIsLocalPreview] = useState(false); + + // Get signed URL for S3 keys + const { data: signedUrl } = useSignedImageUrl(previewUrl); const previousPreviewRef = useRef<{ url: string | null; isLocal: boolean; @@ -240,7 +244,9 @@ export const FileInput: React.FC = ({ > {previewUrl && ( File preview + ); + } + + return ( + + ); +} diff --git a/apps/web/components/Tooltip.tsx b/apps/web/components/Tooltip.tsx index 599d46bd8c..a6db805a74 100644 --- a/apps/web/components/Tooltip.tsx +++ b/apps/web/components/Tooltip.tsx @@ -40,7 +40,7 @@ const Tooltip = ({ {kbd.map((key, index) => (
{key}
diff --git a/apps/web/lib/get-image-url.ts b/apps/web/lib/get-image-url.ts index 60fcb202f5..b20314e49b 100644 --- a/apps/web/lib/get-image-url.ts +++ b/apps/web/lib/get-image-url.ts @@ -1,3 +1,16 @@ +/** + * Helper to check if an imageKey is an S3 key that needs RPC resolution + * @param imageKeyOrUrl - Can be an S3 key or a legacy URL + * @returns true if it's an S3 key that needs RPC resolution + */ +export function isS3Key(imageKeyOrUrl: string | null | undefined): boolean { + if (!imageKeyOrUrl) return false; + return ( + imageKeyOrUrl.startsWith("users/") || + imageKeyOrUrl.startsWith("organizations/") + ); +} + /** * Helper to convert an imageKey (S3 key) or legacy URL to a usable URL * @param imageKeyOrUrl - Can be an S3 key (starts with "users/" or "organizations/") or a legacy URL @@ -7,13 +20,10 @@ export function getImageUrl( imageKeyOrUrl: string | null | undefined, ): string | null { if (!imageKeyOrUrl) return null; - // - // If it's an S3 key (starts with users/ or organizations/), convert to API route - if ( - imageKeyOrUrl.startsWith("users/") || - imageKeyOrUrl.startsWith("organizations/") - ) { - return `/api/icon?key=${encodeURIComponent(imageKeyOrUrl)}`; + + // If it's an S3 key, return as-is (will be handled by RPC hook) + if (isS3Key(imageKeyOrUrl)) { + return imageKeyOrUrl; } // Otherwise, return as-is (legacy URL or external URL) diff --git a/apps/web/lib/use-signed-image-url.ts b/apps/web/lib/use-signed-image-url.ts new file mode 100644 index 0000000000..39c4e27f3d --- /dev/null +++ b/apps/web/lib/use-signed-image-url.ts @@ -0,0 +1,28 @@ +import { Effect } from "effect"; +import { useEffectQuery } from "@/lib/EffectRuntime"; +import { withRpc } from "./Rpcs"; + +/** + * Hook to get signed URL for an S3 image key + * @param key - S3 key (starts with "users/" or "organizations/") + * @returns Object with url, isLoading, error + */ +export function useSignedImageUrl(key: string | null | undefined) { + return useEffectQuery({ + queryKey: ["signedImageUrl", key], + queryFn: () => { + if ( + !key || + (!key.startsWith("users/") && !key.startsWith("organizations/")) + ) { + return Effect.succeed(key); + } + + return withRpc((rpc) => rpc.GetSignedImageUrl({ key })) + .pipe(Effect.map((result) => result.url)) + .pipe(Effect.catchTag("InternalError", () => Effect.succeed(null))); + }, + enabled: + !!key && (key.startsWith("users/") || key.startsWith("organizations/")), + }); +} diff --git a/packages/web-backend/src/Users/UsersRpcs.ts b/packages/web-backend/src/Users/UsersRpcs.ts index ce4b32abdc..f4ff2b6b4e 100644 --- a/packages/web-backend/src/Users/UsersRpcs.ts +++ b/packages/web-backend/src/Users/UsersRpcs.ts @@ -1,10 +1,12 @@ import { InternalError, User } from "@cap/web-domain"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option } from "effect"; +import { S3Buckets } from "../S3Buckets"; import { UsersOnboarding } from "./UsersOnboarding"; export const UsersRpcsLive = User.UserRpcs.toLayer( Effect.gen(function* () { const onboarding = yield* UsersOnboarding; + const s3Buckets = yield* S3Buckets; return { UserCompleteOnboardingStep: (payload) => Effect.gen(function* () { @@ -37,6 +39,28 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( () => new InternalError({ type: "database" }), ), ), + GetSignedImageUrl: (payload) => + Effect.gen(function* () { + // Validate that the key is for a user or organization image + if ( + !payload.key.startsWith("users/") && + !payload.key.startsWith("organizations/") + ) { + return yield* Effect.fail(new InternalError({ type: "unknown" })); + } + + const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); + const url = yield* bucket.getSignedObjectUrl(payload.key); + + return { url }; + }).pipe( + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchAll(() => new InternalError({ type: "unknown" })), + ), }; }), ).pipe(Layer.provide(UsersOnboarding.Default)); diff --git a/packages/web-domain/src/User.ts b/packages/web-domain/src/User.ts index 2f5f96fdd3..8351e110fa 100644 --- a/packages/web-domain/src/User.ts +++ b/packages/web-domain/src/User.ts @@ -68,10 +68,23 @@ export const OnboardingStepResult = Schema.Union( }), ); +export const GetSignedImageUrlPayload = Schema.Struct({ + key: Schema.String, +}); + +export const GetSignedImageUrlResult = Schema.Struct({ + url: Schema.String, +}); + export class UserRpcs extends RpcGroup.make( Rpc.make("UserCompleteOnboardingStep", { payload: OnboardingStepPayload, success: OnboardingStepResult, error: InternalError, }).middleware(RpcAuthMiddleware), + Rpc.make("GetSignedImageUrl", { + payload: GetSignedImageUrlPayload, + success: GetSignedImageUrlResult, + error: InternalError, + }).middleware(RpcAuthMiddleware), ) {} From 2fbf768e58ec2f278dacf282b62bf3adada8bb5f Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:23:02 +0300 Subject: [PATCH 3/9] adjust user profile placeholder --- .../account/components/ProfileImage.tsx | 22 ++++++++++++++----- apps/web/components/SignedImageUrl.tsx | 1 + packages/ui/src/components/Avatar.tsx | 1 + 3 files changed, 18 insertions(+), 6 deletions(-) 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 628b3638f0..7b129fe2b4 100644 --- a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@cap/ui"; -import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faImage, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; @@ -88,11 +88,21 @@ export function ProfileImage({ previewUrl ? "border-solid" : "border-dashed", )} > - + {previewUrl ? ( + + ) : ( +
+ +
+ )}
= ({ From a810e473ab8089ed9310dd0eada3e92b7193b7e3 Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:46:03 +0300 Subject: [PATCH 4/9] refactor some code + rollback name changes of columns --- .../actions/account/remove-profile-image.ts | 15 ++++----- .../actions/account/upload-profile-image.ts | 14 ++++----- apps/web/actions/organization/create-space.ts | 14 ++++----- apps/web/actions/organization/remove-icon.ts | 12 +++---- apps/web/actions/organization/update-space.ts | 15 ++++----- .../organization/upload-organization-icon.ts | 8 ++--- .../actions/organization/upload-space-icon.ts | 17 +++++----- apps/web/actions/videos/new-comment.ts | 2 +- .../(org)/dashboard/_components/MobileTab.tsx | 6 ++-- .../dashboard/_components/Navbar/Items.tsx | 8 ++--- .../_components/Navbar/SpaceDialog.tsx | 11 ++++--- .../_components/Navbar/SpacesList.tsx | 3 +- .../dashboard/_components/Navbar/Top.tsx | 6 ++-- apps/web/app/(org)/dashboard/caps/Caps.tsx | 2 +- .../caps/components/SharingDialog.tsx | 7 +++-- apps/web/app/(org)/dashboard/caps/page.tsx | 16 +++++----- .../web/app/(org)/dashboard/dashboard-data.ts | 12 +++---- .../[id]/components/ClientMyCapsLink.tsx | 3 +- apps/web/app/(org)/dashboard/refer/page.tsx | 2 +- .../dashboard/settings/account/Settings.tsx | 2 +- .../account/components/ProfileImage.tsx | 3 +- .../components/OrganizationIcon.tsx | 3 +- .../[spaceId]/components/MembersIndicator.tsx | 4 +-- .../components/OrganizationIndicator.tsx | 4 ++- .../(org)/dashboard/spaces/[spaceId]/page.tsx | 6 ++-- .../(org)/dashboard/spaces/browse/page.tsx | 5 +-- .../(org)/onboarding/components/Bottom.tsx | 2 +- .../s/[videoId]/_components/ShareHeader.tsx | 3 +- .../app/s/[videoId]/_components/Toolbar.tsx | 4 +-- .../_components/tabs/Activity/Comments.tsx | 4 +-- apps/web/app/s/[videoId]/page.tsx | 18 +++++------ apps/web/components/FileInput.tsx | 14 +++++++-- apps/web/components/SignedImageUrl.tsx | 23 +++++++++----- apps/web/components/forms/NewOrganization.tsx | 1 + apps/web/components/forms/server.ts | 4 +-- apps/web/lib/folder.ts | 14 ++++----- apps/web/lib/get-image-url.ts | 31 ------------------- apps/web/lib/use-signed-image-url.ts | 20 ++++++------ packages/database/schema.ts | 6 ++-- packages/web-backend/src/Users/UsersRpcs.ts | 8 ----- packages/web-domain/src/User.ts | 1 + 41 files changed, 166 insertions(+), 187 deletions(-) delete mode 100644 apps/web/lib/get-image-url.ts diff --git a/apps/web/actions/account/remove-profile-image.ts b/apps/web/actions/account/remove-profile-image.ts index 2ca23e4cd9..81eb38286f 100644 --- a/apps/web/actions/account/remove-profile-image.ts +++ b/apps/web/actions/account/remove-profile-image.ts @@ -16,15 +16,15 @@ export async function removeProfileImage() { throw new Error("Unauthorized"); } - const imageUrlOrKey = user.imageUrlOrKey; + const image = user.image; // Delete the profile image from S3 if it exists - if (imageUrlOrKey) { + if (image) { try { // Extract the S3 key - handle both old URL format and new key format - let s3Key = imageUrlOrKey; - if (imageUrlOrKey.includes("amazonaws.com")) { - const url = new URL(imageUrlOrKey); + let s3Key = image; + if (image.includes("amazonaws.com")) { + const url = new URL(image); s3Key = url.pathname.substring(1); // Remove leading slash } @@ -41,10 +41,7 @@ export async function removeProfileImage() { } } - await db() - .update(users) - .set({ imageUrlOrKey: null }) - .where(eq(users.id, user.id)); + await db().update(users).set({ image: null }).where(eq(users.id, user.id)); revalidatePath("/dashboard/settings/account"); diff --git a/apps/web/actions/account/upload-profile-image.ts b/apps/web/actions/account/upload-profile-image.ts index 96a53f5f10..6d72dbfec6 100644 --- a/apps/web/actions/account/upload-profile-image.ts +++ b/apps/web/actions/account/upload-profile-image.ts @@ -43,13 +43,13 @@ export async function uploadProfileImage(formData: FormData) { } // Get the old profile image to delete it later - const oldImageUrlOrKey = user.imageUrlOrKey; + const oldImageUrlOrKey = user.image; const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`; try { const sanitizedFile = await sanitizeFile(file); - let imageUrlOrKey: string | null = null; + let image: string | null = null; await Effect.gen(function* () { const [bucket] = yield* S3Buckets.getBucketAccess(Option.none()); @@ -83,23 +83,23 @@ export async function uploadProfileImage(formData: FormData) { contentType: file.type, }); - imageUrlOrKey = fileKey; + image = fileKey; }).pipe(runPromise); - if (!imageUrlOrKey) { + if (!image) { throw new Error("Failed to resolve uploaded profile image key"); } - const finalImageUrlOrKey = imageUrlOrKey; + const finalImageUrlOrKey = image; await db() .update(users) - .set({ imageUrlOrKey: finalImageUrlOrKey }) + .set({ image: finalImageUrlOrKey }) .where(eq(users.id, user.id)); revalidatePath("/dashboard/settings/account"); - return { success: true, imageUrlOrKey: finalImageUrlOrKey } 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 172cb8f4ad..698a26e3fa 100644 --- a/apps/web/actions/organization/create-space.ts +++ b/apps/web/actions/organization/create-space.ts @@ -16,7 +16,7 @@ interface CreateSpaceResponse { success: boolean; spaceId?: string; name?: string; - iconUrlOrKey?: string | null; + iconUrl?: string | null; error?: string; } @@ -65,7 +65,7 @@ export async function createSpace( const spaceId = Space.SpaceId.make(nanoId()); const iconFile = formData.get("icon") as File | null; - let iconUrlOrKey = null; + let iconUrl = null; if (iconFile) { // Validate file type @@ -99,7 +99,7 @@ export async function createSpace( yield* Effect.promise(() => iconFile.bytes()), { contentType: iconFile.type }, ); - iconUrlOrKey = fileKey; + iconUrl = fileKey; }).pipe(runPromise); } catch (error) { console.error("Error uploading space icon:", error); @@ -117,10 +117,8 @@ export async function createSpace( name, organizationId: user.activeOrganizationId, createdById: user.id, - iconUrlOrKey, - description: iconUrlOrKey - ? `Space with custom icon: ${iconUrlOrKey}` - : null, + iconUrl, + description: iconUrl ? `Space with custom icon: ${iconUrl}` : null, createdAt: new Date(), updatedAt: new Date(), }); @@ -186,7 +184,7 @@ export async function createSpace( success: true, spaceId, name, - iconUrlOrKey, + iconUrl, }; } catch (error) { console.error("Error creating space:", error); diff --git a/apps/web/actions/organization/remove-icon.ts b/apps/web/actions/organization/remove-icon.ts index f1a751e451..4bd416c10c 100644 --- a/apps/web/actions/organization/remove-icon.ts +++ b/apps/web/actions/organization/remove-icon.ts @@ -32,15 +32,15 @@ export async function removeOrganizationIcon( throw new Error("Only the owner can remove the organization icon"); } - const iconUrlOrKey = organization[0]?.iconUrlOrKey; + const iconUrl = organization[0]?.iconUrl; // Delete the icon from S3 if it exists - if (iconUrlOrKey) { + if (iconUrl) { try { // Extract the S3 key - handle both old URL format and new key format - let s3Key = iconUrlOrKey; - if (iconUrlOrKey.includes("amazonaws.com")) { - const url = new URL(iconUrlOrKey); + let s3Key = iconUrl; + if (iconUrl.includes("amazonaws.com")) { + const url = new URL(iconUrl); s3Key = url.pathname.substring(1); // Remove leading slash } @@ -61,7 +61,7 @@ export async function removeOrganizationIcon( await db() .update(organizations) .set({ - iconUrlOrKey: null, + iconUrl: null, }) .where(eq(organizations.id, organizationId)); diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index 51e556afc1..bc4805b2ef 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -48,14 +48,14 @@ export async function updateSpace(formData: FormData) { // Handle icon removal if requested if (formData.get("removeIcon") === "true") { - // Remove icon from S3 and set iconUrlOrKey to null + // Remove icon from S3 and set iconUrl to null const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id)); const space = spaceArr[0]; - if (space?.iconUrlOrKey) { + if (space?.iconUrl) { // Extract the S3 key (it might already be a key or could be a legacy URL) - const key = space.iconUrlOrKey.startsWith("organizations/") - ? space.iconUrlOrKey - : space.iconUrlOrKey.match(/organizations\/.+/)?.[0]; + const key = space.iconUrl.startsWith("organizations/") + ? space.iconUrl + : space.iconUrl.match(/organizations\/.+/)?.[0]; if (key) { try { @@ -68,10 +68,7 @@ export async function updateSpace(formData: FormData) { } } } - await db() - .update(spaces) - .set({ iconUrlOrKey: null }) - .where(eq(spaces.id, id)); + await db().update(spaces).set({ iconUrl: null }).where(eq(spaces.id, id)); } else if (iconFile && iconFile.size > 0) { await uploadSpaceIcon(formData, id); } diff --git a/apps/web/actions/organization/upload-organization-icon.ts b/apps/web/actions/organization/upload-organization-icon.ts index 93147846b6..e62582228b 100644 --- a/apps/web/actions/organization/upload-organization-icon.ts +++ b/apps/web/actions/organization/upload-organization-icon.ts @@ -52,7 +52,7 @@ export async function uploadOrganizationIcon( } // Get the old icon to delete it later - const oldIconUrlOrKey = organization[0]?.iconUrlOrKey; + const oldIconUrlOrKey = organization[0]?.iconUrl; // Create a unique file key const fileExtension = file.name.split(".").pop(); @@ -92,16 +92,16 @@ export async function uploadOrganizationIcon( yield* bucket.putObject(fileKey, bodyBytes, { contentType: file.type }); }).pipe(runPromise); - const iconUrlOrKey = fileKey; + const iconUrl = fileKey; await db() .update(organizations) - .set({ iconUrlOrKey }) + .set({ iconUrl }) .where(eq(organizations.id, organizationId)); revalidatePath("/dashboard/settings/organization"); - return { success: true, iconUrlOrKey }; + return { success: true, iconUrl }; } catch (error) { console.error("Error uploading organization icon:", error); throw new Error(error instanceof Error ? error.message : "Upload failed"); diff --git a/apps/web/actions/organization/upload-space-icon.ts b/apps/web/actions/organization/upload-space-icon.ts index 3bdff94124..8918859144 100644 --- a/apps/web/actions/organization/upload-space-icon.ts +++ b/apps/web/actions/organization/upload-space-icon.ts @@ -64,11 +64,11 @@ export async function uploadSpaceIcon( try { // Remove previous icon if exists - if (space.iconUrlOrKey) { + if (space.iconUrl) { // Extract the S3 key (it might already be a key or could be a legacy URL) - const key = space.iconUrlOrKey.startsWith("organizations/") - ? space.iconUrlOrKey - : space.iconUrlOrKey.match(/organizations\/.+/)?.[0]; + const key = space.iconUrl.startsWith("organizations/") + ? space.iconUrl + : space.iconUrl.match(/organizations\/.+/)?.[0]; if (key) { try { await bucket.deleteObject(key).pipe(runPromise); @@ -89,15 +89,12 @@ export async function uploadSpaceIcon( ) .pipe(runPromise); - const iconUrlOrKey = fileKey; + const iconUrl = fileKey; - await db() - .update(spaces) - .set({ iconUrlOrKey }) - .where(eq(spaces.id, spaceId)); + await db().update(spaces).set({ iconUrl }).where(eq(spaces.id, spaceId)); revalidatePath("/dashboard"); - return { success: true, iconUrlOrKey }; + return { success: true, iconUrl }; } catch (error) { console.error("Error uploading space icon:", error); throw new Error(error instanceof Error ? error.message : "Upload failed"); diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index 55def02bcb..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, - authorImageUrlOrKey: user.imageUrlOrKey ?? 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 826ce3ba27..ce1b469a9a 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -76,8 +76,9 @@ const Orgs = ({ className="flex gap-1.5 items-center p-2 rounded-full border bg-gray-3 border-gray-5" > @@ -135,8 +136,9 @@ const OrgsMenu = ({ >
diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index b9866e2934..4515df3fe4 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -123,10 +123,11 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { >
{ >
diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index 1bafa961dc..a8fb79804c 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -37,7 +37,7 @@ interface SpaceDialogProps { id: string; name: string; members: string[]; - iconUrlOrKey?: string; + iconUrl?: string; } | null; onSpaceUpdated?: () => void; } @@ -118,7 +118,7 @@ export interface NewSpaceFormProps { id: string; name: string; members: string[]; - iconUrlOrKey?: string; + iconUrl?: string; } | null; } @@ -185,7 +185,7 @@ export const NewSpaceForm: React.FC = (props) => { if (edit && space?.id) { formData.append("id", space.id); // If the user removed the icon, send a removeIcon flag - if (selectedFile === null && space.iconUrlOrKey) { + if (selectedFile === null && space.iconUrl) { formData.append("removeIcon", "true"); } await updateSpace(formData); @@ -256,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.imageUrlOrKey ?? undefined, + image: m.user.image ?? undefined, }))} onSelect={(selected) => field.onChange(selected.map((opt) => opt.value)) @@ -278,7 +278,8 @@ export const NewSpaceForm: React.FC = (props) => { void }) => { )} > {
{activeSpace && ( @@ -261,8 +262,9 @@ const User = () => { >
diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx index a61f0bd3e9..bc0ff17e6b 100644 --- a/apps/web/app/(org)/dashboard/caps/Caps.tsx +++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx @@ -40,7 +40,7 @@ export type VideoData = { sharedSpaces?: { id: string; name: string; - iconUrlOrKey: string; + iconUrl: string; isOrg: boolean; organizationId: string; }[]; diff --git a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx index 816d161e27..5db9749c08 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SharingDialog.tsx @@ -32,7 +32,7 @@ interface SharingDialogProps { sharedSpaces: { id: string; name: string; - iconUrlOrKey?: string | null; + iconUrl?: string | null; organizationId: string; }[]; onSharingUpdated: (updatedSharedSpaces: string[]) => void; @@ -388,7 +388,7 @@ const SpaceCard = ({ space: { id: string; name: string; - iconUrlOrKey?: string | null; + iconUrl?: string | null; organizationId: string; }; selectedSpaces: Set; @@ -417,8 +417,9 @@ const SpaceCard = ({ onClick={() => handleToggleSpace(space.id)} > diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index fdd301fc0e..f86da3da91 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -33,7 +33,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: spaces.id, name: spaces.name, organizationId: spaces.organizationId, - iconUrlOrKey: organizations.iconUrlOrKey, + iconUrl: organizations.iconUrl, }) .from(spaceVideos) .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) @@ -47,7 +47,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: organizations.id, name: organizations.name, organizationId: organizations.id, - iconUrlOrKey: organizations.iconUrlOrKey, + iconUrl: organizations.iconUrl, }) .from(sharedVideos) .innerJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) @@ -60,7 +60,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: string; name: string; organizationId: string; - iconUrlOrKey: string; + iconUrl: string; isOrg: boolean; }> > = {}; @@ -74,7 +74,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: space.id, name: space.name, organizationId: space.organizationId, - iconUrlOrKey: space.iconUrlOrKey || "", + iconUrl: space.iconUrl || "", isOrg: false, }); }); @@ -88,7 +88,7 @@ async function getSharedSpacesForVideos(videoIds: Video.VideoId[]) { id: org.id, name: org.name, organizationId: org.organizationId, - iconUrlOrKey: org.iconUrlOrKey || "", + iconUrl: org.iconUrl || "", isOrg: true, }); }); @@ -134,15 +134,13 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { public: videos.public, totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, - sharedOrganizations: sql< - { id: string; name: string; iconUrlOrKey: string }[] - >` + sharedOrganizations: sql<{ id: string; name: string; iconUrl: string }[]>` COALESCE( JSON_ARRAYAGG( JSON_OBJECT( 'id', ${organizations.id}, 'name', ${organizations.name}, - 'iconUrlOrKey', ${organizations.iconUrlOrKey} + 'iconUrl', ${organizations.iconUrl} ) ), JSON_ARRAY() diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index e6e457bafe..4c54c06e8a 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -18,7 +18,7 @@ export type Organization = { members: (typeof organizationMembers.$inferSelect & { user: Pick< typeof users.$inferSelect, - "id" | "name" | "email" | "lastName" | "imageUrlOrKey" + "id" | "name" | "email" | "lastName" | "image" >; })[]; invites: (typeof organizationInvites.$inferSelect)[]; @@ -47,14 +47,14 @@ export async function getDashboardData(user: typeof userSelectProps) { organization: organizations, settings: organizations.settings, member: organizationMembers, - iconUrlOrKey: organizations.iconUrlOrKey, + iconUrl: organizations.iconUrl, user: { id: users.id, name: users.name, lastName: users.lastName, email: users.email, inviteQuota: users.inviteQuota, - imageUrlOrKey: users.imageUrlOrKey, + image: users.image, defaultOrgId: users.defaultOrgId, }, }) @@ -130,7 +130,7 @@ export async function getDashboardData(user: typeof userSelectProps) { description: spaces.description, organizationId: spaces.organizationId, createdById: spaces.createdById, - iconUrlOrKey: spaces.iconUrlOrKey, + iconUrl: spaces.iconUrl, memberCount: sql`( SELECT COUNT(*) FROM space_members WHERE space_members.spaceId = spaces.id )`, @@ -204,7 +204,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, - iconUrlOrKey: activeOrgInfo.organization.iconUrlOrKey, + iconUrl: activeOrgInfo.organization.iconUrl, memberCount: orgMemberCount, createdById: activeOrgInfo.organization.ownerId, videoCount: orgVideoCount, @@ -241,7 +241,7 @@ export async function getDashboardData(user: typeof userSelectProps) { name: users.name, lastName: users.lastName, email: users.email, - imageUrlOrKey: users.imageUrlOrKey, + image: users.image, }, }) .from(organizationMembers) 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 ccdd033012..ca6c2de19a 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx @@ -126,8 +126,9 @@ export function ClientMyCapsLink({ > {activeSpace && ( diff --git a/apps/web/app/(org)/dashboard/refer/page.tsx b/apps/web/app/(org)/dashboard/refer/page.tsx index 16c5bf7e16..4d953784ad 100644 --- a/apps/web/app/(org)/dashboard/refer/page.tsx +++ b/apps/web/app/(org)/dashboard/refer/page.tsx @@ -54,7 +54,7 @@ export default async function ReferPage() { user.id, user.name, user.email, - user.imageUrlOrKey, + user.image, ); return ; diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 63d5103bda..a1dd015f60 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -35,7 +35,7 @@ export const Settings = ({ const firstNameId = useId(); const lastNameId = useId(); const contactEmailId = useId(); - const initialProfileImage = user?.imageUrlOrKey ?? null; + const initialProfileImage = user?.image ?? null; const [profileImageOverride, setProfileImageOverride] = useState< string | null | undefined >(undefined); 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 7b129fe2b4..d494ab89a6 100644 --- a/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/components/ProfileImage.tsx @@ -90,8 +90,9 @@ export function ProfileImage({ > {previewUrl ? ( diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx index 4159583d4b..1dcb3bf18a 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx @@ -14,7 +14,7 @@ export const OrganizationIcon = () => { const iconInputId = useId(); const { activeOrganization } = useDashboardContext(); const organizationId = activeOrganization?.organization.id; - const existingIconUrl = activeOrganization?.organization.iconUrlOrKey ?? null; + const existingIconUrl = activeOrganization?.organization.iconUrl ?? null; const [isUploading, setIsUploading] = useState(false); @@ -73,6 +73,7 @@ export const OrganizationIcon = () => { previewIconSize={20} id={iconInputId} name="icon" + type="organization" onChange={handleFileChange} disabled={isUploading} isLoading={isUploading} 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 95ba8832e3..81bf454abd 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/MembersIndicator.tsx @@ -99,7 +99,7 @@ export const MembersIndicator = ({ .map((m) => ({ value: m.userId, label: m.name || m.email, - image: m.imageUrlOrKey || undefined, + image: m.image || undefined, })); }, [organizationMembers, user], @@ -167,7 +167,7 @@ export const MembersIndicator = ({ > 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..5a50a777d8 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/OrganizationIndicator.tsx @@ -101,7 +101,9 @@ export const OrganizationIndicator = ({

{member.role} diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 678b0cf5f1..750d527067 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -29,7 +29,7 @@ export type SpaceMemberData = { id: string; userId: string; role: string; - imageUrlOrKey?: string | null; + image?: string | null; name: string | null; email: string; }; @@ -63,7 +63,7 @@ async function fetchSpaceMembers(spaceId: Space.SpaceIdOrOrganisationId) { role: sql`'member'`, name: users.name, email: users.email, - imageUrlOrKey: users.imageUrlOrKey, + image: users.image, }) .from(spaceMembers) .innerJoin(users, eq(spaceMembers.userId, users.id)) @@ -78,7 +78,7 @@ async function fetchOrganizationMembers(orgId: Organisation.OrganisationId) { role: organizationMembers.role, name: users.name, email: users.email, - imageUrlOrKey: users.imageUrlOrKey, + image: users.image, }) .from(organizationMembers) .innerJoin(users, eq(organizationMembers.userId, users.id)) diff --git a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx index f2e30f7f69..a8cabfe57a 100644 --- a/apps/web/app/(org)/dashboard/spaces/browse/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/browse/page.tsx @@ -133,8 +133,9 @@ export default function BrowseSpacesPage() { > @@ -168,7 +169,7 @@ export default function BrowseSpacesPage() { members: (trueActiveOrgMembers || []).map( (m: { user: { id: string } }) => m.user.id, ), - iconUrlOrKey: space.iconUrlOrKey, + iconUrl: space.iconUrl, }); setShowSpaceDialog(true); }} 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 = () => {

) : ( -
+

...

)} diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 441c79a526..ef7d9a270c 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -287,8 +287,8 @@ export const Share = ({
-
-
+
+
void; @@ -62,12 +62,15 @@ const CommentStamp: React.FC = ({
{/* User avatar/initial */} - + {comment.authorName && ( + + )} {/* Comment content */}
diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index e21ab3e92b..6dd3c6529a 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -187,7 +187,7 @@ export const ShareVideo = forwardRef< {data.source.type === "desktopMP4" ? ( Date: Tue, 21 Oct 2025 13:24:53 +0300 Subject: [PATCH 9/9] Update media-player.tsx --- apps/web/app/s/[videoId]/_components/video/media-player.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/s/[videoId]/_components/video/media-player.tsx b/apps/web/app/s/[videoId]/_components/video/media-player.tsx index d85a6c9a6b..3d576b71c9 100644 --- a/apps/web/app/s/[videoId]/_components/video/media-player.tsx +++ b/apps/web/app/s/[videoId]/_components/video/media-player.tsx @@ -2340,7 +2340,8 @@ function MediaPlayerVolume(props: MediaPlayerVolumeProps) { className={cn( "flex relative items-center select-none touch-none", expandable - ? "w-0 opacity-0 transition-[width,opacity] duration-200 ease-in-out group-focus-within:w-16 group-focus-within:opacity-100 group-hover:w-16 group-hover:opacity-100":"w-16", + ? "w-0 opacity-0 transition-[width,opacity] duration-200 ease-in-out group-focus-within:w-16 group-focus-within:opacity-100 group-hover:w-16 group-hover:opacity-100" + : "w-16", className, )} disabled={isDisabled}