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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/web/actions/organization/remove-icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use server';

import { getCurrentUser } from "@cap/database/auth/session";
import { organizations } from "@cap/database/schema";
import { db } from "@cap/database";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function removeOrganizationIcon(organizationId: string) {
const user = await getCurrentUser();

if (!user) {
throw new Error("Unauthorized");
}

const organization = await db()
.select()
.from(organizations)
.where(eq(organizations.id, organizationId));

if (!organization || organization.length === 0) {
throw new Error("Organization not found");
}

if (organization[0]?.ownerId !== user.id) {
throw new Error("Only the owner can remove the organization icon");
}

// Update organization to remove icon URL
await db()
.update(organizations)
.set({
iconUrl: null,
})
.where(eq(organizations.id, organizationId));

revalidatePath("/dashboard/settings/organization");

return { success: true };
}
114 changes: 114 additions & 0 deletions apps/web/actions/organization/upload-icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use server';

import { getCurrentUser } from "@cap/database/auth/session";
import { organizations } from "@cap/database/schema";
import { db } from "@cap/database";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { createS3Client, getS3Bucket } from "@/utils/s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { serverEnv } from "@cap/env";

export async function uploadOrganizationIcon(
formData: FormData,
organizationId: string
) {
const user = await getCurrentUser();

if (!user) {
throw new Error("Unauthorized");
}

const organization = await db()
.select()
.from(organizations)
.where(eq(organizations.id, organizationId));

if (!organization || organization.length === 0) {
throw new Error("Organization not found");
}

if (organization[0]?.ownerId !== user.id) {
throw new Error("Only the owner can update organization icon");
}

const file = formData.get('file') as File;

if (!file) {
throw new Error("No file provided");
}

// Validate file type
if (!file.type.startsWith('image/')) {
throw new Error("File must be an image");
}

// Validate file size (limit to 2MB)
if (file.size > 2 * 1024 * 1024) {
throw new Error("File size must be less than 2MB");
}

// Create a unique file key
const fileExtension = file.name.split('.').pop();
const fileKey = `organizations/${organizationId}/icon-${Date.now()}.${fileExtension}`;

try {
// Get S3 client
const [s3Client] = await createS3Client();
const bucketName = await getS3Bucket();

// Create presigned post
const presignedPostData = await createPresignedPost(s3Client, {
Bucket: bucketName,
Key: fileKey,
Fields: {
'Content-Type': file.type,
},
Expires: 600, // 10 minutes
});

// Upload file to S3
const formDataForS3 = new FormData();
Object.entries(presignedPostData.fields).forEach(([key, value]) => {
formDataForS3.append(key, value as string);
});
formDataForS3.append('file', file);

const uploadResponse = await fetch(presignedPostData.url, {
method: 'POST',
body: formDataForS3,
});

if (!uploadResponse.ok) {
throw new Error("Failed to upload file to S3");
}

// Construct the icon URL
let iconUrl;
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}/${bucketName}/${fileKey}`;
} else {
// Default AWS S3 URL format
iconUrl = `https://${bucketName}.s3.${serverEnv().CAP_AWS_REGION || 'us-east-1'}.amazonaws.com/${fileKey}`;
}

// Update organization with new icon URL
await db()
.update(organizations)
.set({
iconUrl,
})
.where(eq(organizations.id, organizationId));

revalidatePath("/dashboard/settings/organization");

return { success: true, iconUrl };
} catch (error) {
console.error("Error uploading organization icon:", error);
throw new Error(error instanceof Error ? error.message : "Upload failed");
}
}
55 changes: 42 additions & 13 deletions apps/web/app/dashboard/_components/AdminNavbar/AdminNavItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import { motion } from "framer-motion";
import Image from "next/image";

import { useRef, useState } from "react";
import { updateActiveOrganization } from "./server";
Expand Down Expand Up @@ -117,14 +118,25 @@ export const AdminNavItems = ({ collapsed }: { collapsed?: boolean }) => {
>
<div className="flex justify-between items-center w-full text-left">
<div className="flex items-center">
<Avatar
letterClass="text-gray-1 text-xs"
className="relative flex-shrink-0 size-5"
name={
activeOrg?.organization.name ??
"No organization found"
}
/>
{activeOrg?.organization.iconUrl ? (
<div className="overflow-hidden relative flex-shrink-0 rounded-full size-5">
<Image
src={activeOrg.organization.iconUrl}
alt={activeOrg.organization.name || "Organization icon"}
fill
className="object-cover"
/>
</div>
) : (
<Avatar
letterClass="text-gray-1 text-xs"
className="relative flex-shrink-0 size-5"
name={
activeOrg?.organization.name ??
"No organization found"
}
/>
)}
<p className="ml-2.5 text-sm text-gray-12 font-medium truncate">
{activeOrg?.organization.name ??
"No organization found"}
Expand Down Expand Up @@ -152,9 +164,8 @@ export const AdminNavItems = ({ collapsed }: { collapsed?: boolean }) => {
return (
<CommandItem
className={clsx(
"transition-colors duration-300",
isSelected ? "pointer-events-none text-gray-12"
: "!text-gray-10 hover:!text-gray-12"
"rounded-lg transition-colors duration-300 group",
isSelected ? "pointer-events-none":"text-gray-10 hover:text-gray-12 hover:bg-gray-6"
)}
key={organization.organization.name + "-organization"}
onSelect={async () => {
Expand All @@ -164,11 +175,29 @@ export const AdminNavItems = ({ collapsed }: { collapsed?: boolean }) => {
setOpen(false);
}}
>
{organization.organization.name}
<div className="flex gap-2 items-center w-full">
{organization.organization.iconUrl ? (
<div className="overflow-hidden relative flex-shrink-0 rounded-full size-5">
<Image
src={organization.organization.iconUrl}
alt={organization.organization.name || "Organization icon"}
fill
className="object-cover"
/>
</div>
) : (
<Avatar
letterClass="text-gray-1 text-xs"
className="relative flex-shrink-0 size-5"
name={organization.organization.name}
/>
)}
<p className={clsx("flex-1 text-sm transition-colors duration-200 group-hover:text-gray-12", isSelected ? "text-gray-12":"text-gray-10")}>{organization.organization.name}</p>
</div>
{isSelected && (
<Check
size={18}
className={"ml-auto"}
className={"ml-auto text-gray-12"}
/>
)}
</CommandItem>
Expand Down
12 changes: 6 additions & 6 deletions apps/web/app/dashboard/caps/components/CapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ interface Props extends PropsWithChildren {
createdAt: Date;
totalComments: number;
totalReactions: number;
sharedOrganizations?: { id: string; name: string }[];
sharedOrganizations?: { id: string; name: string; iconUrl: string }[];
ownerName: string | null;
metadata?: VideoMetadata;
};
analytics: number;
onDelete?: (videoId: string) => Promise<void>;
userId?: string;
userOrganizations?: { id: string; name: string }[];
userOrganizations?: { id: string; name: string; iconUrl: string }[];
sharedCapCard?: boolean;
isSelected?: boolean;
onSelectToggle?: () => void;
Expand Down Expand Up @@ -413,24 +413,24 @@ export const CapCard = ({
)}
</div>
{renderSharedStatus()}
<div className="mb-1">
<div className="mb-1 h-[1.5rem]"> {/* Fixed height container */}
{isDateEditing && !sharedCapCard ? (
<div className="flex items-center">
<div className="flex items-center h-full">
<input
type="text"
value={dateValue}
onChange={handleDateChange}
onBlur={handleDateBlur}
onKeyDown={handleDateKeyDown}
autoFocus
className="text-sm truncate mt-2 leading-[1.25rem] text-gray-10 bg-transparent focus:outline-none"
className="text-sm w-full truncate text-gray-10 bg-transparent focus:outline-none h-full leading-[1.5rem]"
placeholder="YYYY-MM-DD HH:mm:ss"
/>
</div>
) : (
<Tooltip content={`Cap created at ${effectiveDate}`}>
<p
className="text-sm truncate mt-2 leading-[1.25rem] text-gray-10 cursor-pointer flex items-center"
className="text-sm truncate text-gray-10 cursor-pointer flex items-center h-full leading-[1.5rem]"
onClick={handleDateClick}
>
{showFullDate
Expand Down
Loading
Loading