diff --git a/.gitignore b/.gitignore index b6b85df6d..26a5f848a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ next-env.d.ts # AI agents and related files CLAUDE.md +.cursor/ .agent diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 9d0a4fbac..f3725972e 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -386,6 +386,7 @@ components: - blockedReason - isDefault - limits + - createdAt properties: id: type: string @@ -412,6 +413,9 @@ components: type: boolean limits: $ref: "#/components/schemas/UserTeamLimits" + createdAt: + type: string + format: date-time UserTeamsResponse: type: object diff --git a/src/app/dashboard/[teamSlug]/general/page.tsx b/src/app/dashboard/[teamSlug]/general/page.tsx index 60519292a..a8931db70 100644 --- a/src/app/dashboard/[teamSlug]/general/page.tsx +++ b/src/app/dashboard/[teamSlug]/general/page.tsx @@ -1,29 +1,17 @@ -import { InfoCard } from '@/features/dashboard/settings/general/info-card' -import { NameCard } from '@/features/dashboard/settings/general/name-card' -import { ProfilePictureCard } from '@/features/dashboard/settings/general/profile-picture-card' -import Frame from '@/ui/frame' +import { Page } from '@/features/dashboard/layouts/page' +import { TeamAvatar } from '@/features/dashboard/settings/general/team-avatar' +import { TeamInfo } from '@/features/dashboard/settings/general/team-info' +import { TeamName } from '@/features/dashboard/settings/general/team-name' -interface GeneralPageProps { - params: Promise<{ - teamSlug: string - }> -} - -export default async function GeneralPage({ params }: GeneralPageProps) { +export default async function GeneralPage() { return ( - -
-
- - -
- -
- + + +
+ +
+ +
+ ) } diff --git a/src/app/dashboard/[teamSlug]/members/page.tsx b/src/app/dashboard/[teamSlug]/members/page.tsx index 1fbdd1bf9..c4573028c 100644 --- a/src/app/dashboard/[teamSlug]/members/page.tsx +++ b/src/app/dashboard/[teamSlug]/members/page.tsx @@ -1,5 +1,6 @@ import { Page } from '@/features/dashboard/layouts/page' import { MemberCard } from '@/features/dashboard/members/member-card' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' interface MembersPageProps { params: Promise<{ @@ -8,9 +9,15 @@ interface MembersPageProps { } export default async function MembersPage({ params }: MembersPageProps) { + const { teamSlug } = await params + + prefetch(trpc.teams.members.queryOptions({ teamSlug })) + return ( - - - + + + + + ) } diff --git a/src/configs/user-messages.ts b/src/configs/user-messages.ts index 3f51a1b55..c43bb7436 100644 --- a/src/configs/user-messages.ts +++ b/src/configs/user-messages.ts @@ -57,6 +57,12 @@ export const USER_MESSAGES = { failedUpdateLogo: { message: 'Failed to update logo.', }, + teamLogoRemoved: { + message: 'Your team logo has been removed.', + }, + failedRemoveLogo: { + message: 'Failed to remove logo.', + }, emailInUse: { message: 'E-mail already in use.', }, diff --git a/src/core/modules/teams/schemas.ts b/src/core/modules/teams/schemas.ts index 91ea1d39f..b077ff1ae 100644 --- a/src/core/modules/teams/schemas.ts +++ b/src/core/modules/teams/schemas.ts @@ -1,9 +1,7 @@ import { z } from 'zod' import { TeamSlugSchema } from '@/core/shared/schemas/team' -export { TeamSlugSchema } - -export const TeamNameSchema = z +const TeamNameSchema = z .string() .trim() .min(1, { message: 'Team name cannot be empty' }) @@ -13,11 +11,28 @@ export const TeamNameSchema = z 'Names can only contain letters and numbers, separated by spaces, underscores, hyphens, or dots', }) -export const UpdateTeamNameSchema = z.object({ +const UpdateTeamNameSchema = z.object({ teamSlug: TeamSlugSchema, name: TeamNameSchema, }) -export const CreateTeamSchema = z.object({ +const CreateTeamSchema = z.object({ name: TeamNameSchema, }) + +const AddTeamMemberSchema = z.object({ + email: z.email(), +}) + +const RemoveTeamMemberSchema = z.object({ + userId: z.uuid(), +}) + +export { + AddTeamMemberSchema, + CreateTeamSchema, + RemoveTeamMemberSchema, + TeamNameSchema, + TeamSlugSchema, + UpdateTeamNameSchema, +} diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index 219764395..dcb0e1998 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -26,7 +26,7 @@ export interface TeamsRepository { addTeamMember(email: string): Promise> removeTeamMember(userId: string): Promise> updateTeamProfilePictureUrl( - profilePictureUrl: string + profilePictureUrl: string | null ): Promise> } diff --git a/src/core/modules/teams/user-teams-repository.server.ts b/src/core/modules/teams/user-teams-repository.server.ts index 5e4277332..02635d74d 100644 --- a/src/core/modules/teams/user-teams-repository.server.ts +++ b/src/core/modules/teams/user-teams-repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { secondsInDay, secondsInMinute } from 'date-fns/constants' +import { secondsInMinute } from 'date-fns/constants' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { api } from '@/core/shared/clients/api' import { repoErrorFromHttp } from '@/core/shared/errors' @@ -52,11 +52,7 @@ export function createUserTeamsRepository( async listUserTeams(): Promise> { const teamsResult = await listApiUserTeams() - if (!teamsResult.ok) { - return teamsResult - } - - return ok(teamsResult.data) + return teamsResult }, async resolveTeamBySlug( slug: string, diff --git a/src/core/server/actions/team-actions.ts b/src/core/server/actions/team-actions.ts index 16044dca7..b34758704 100644 --- a/src/core/server/actions/team-actions.ts +++ b/src/core/server/actions/team-actions.ts @@ -1,96 +1,13 @@ 'use server' -import { fileTypeFromBuffer } from 'file-type' -import { revalidatePath } from 'next/cache' -import { after } from 'next/server' -import { returnValidationErrors } from 'next-safe-action' -import { z } from 'zod' -import { zfd } from 'zod-form-data' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import type { CreateTeamsResponse } from '@/core/modules/billing/models' -import { - CreateTeamSchema, - UpdateTeamNameSchema, -} from '@/core/modules/teams/schemas' -import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -import { - authActionClient, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' +import { CreateTeamSchema } from '@/core/modules/teams/schemas' +import { authActionClient } from '@/core/server/actions/client' import { handleDefaultInfraError, returnServerError, } from '@/core/server/actions/utils' -import { toActionErrorFromRepoError } from '@/core/server/adapters/errors' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { deleteFile, getFiles, uploadFile } from '@/core/shared/clients/storage' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -const withTeamsRepository = withTeamAuthedRequestRepository( - createTeamsRepository, - (teamsRepository) => ({ teamsRepository }) -) - -export const updateTeamNameAction = authActionClient - .schema(UpdateTeamNameSchema) - .metadata({ actionName: 'updateTeamName' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { name, teamSlug } = parsedInput - const result = await ctx.teamsRepository.updateTeamName(name) - - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - - return result.data - }) - -const AddTeamMemberSchema = z.object({ - teamSlug: TeamSlugSchema, - email: z.email(), -}) - -export const addTeamMemberAction = authActionClient - .schema(AddTeamMemberSchema) - .metadata({ actionName: 'addTeamMember' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { email, teamSlug } = parsedInput - const result = await ctx.teamsRepository.addTeamMember(email) - - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - }) - -const RemoveTeamMemberSchema = z.object({ - teamSlug: TeamSlugSchema, - userId: z.uuid(), -}) - -export const removeTeamMemberAction = authActionClient - .schema(RemoveTeamMemberSchema) - .metadata({ actionName: 'removeTeamMember' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { userId, teamSlug } = parsedInput - const result = await ctx.teamsRepository.removeTeamMember(userId) - - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - }) export const createTeamAction = authActionClient .schema(CreateTeamSchema) @@ -123,100 +40,3 @@ export const createTeamAction = authActionClient return data }) - -const UploadTeamProfilePictureSchema = zfd.formData( - z.object({ - teamSlug: zfd.text(), - image: zfd.file(), - }) -) - -export const uploadTeamProfilePictureAction = authActionClient - .schema(UploadTeamProfilePictureSchema) - .metadata({ actionName: 'uploadTeamProfilePicture' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { image, teamSlug } = parsedInput - const { teamId, teamsRepository } = ctx - - const allowedTypes = ['image/jpeg', 'image/png'] - - if (!allowedTypes.includes(image.type)) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { _errors: ['File must be JPG or PNG format'] }, - }) - } - - const MAX_FILE_SIZE = 5 * 1024 * 1024 - - if (image.size > MAX_FILE_SIZE) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { _errors: ['File size must be less than 5MB'] }, - }) - } - - const arrayBuffer = await image.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - const fileType = await fileTypeFromBuffer(buffer) - - if (!fileType) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { _errors: ['Unable to determine file type'] }, - }) - } - - const allowedMimeTypes = ['image/jpeg', 'image/png'] - if (!allowedMimeTypes.includes(fileType.mime)) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { - _errors: [ - 'Invalid file type. Only JPEG and PNG images are allowed. File appears to be: ' + - fileType.mime, - ], - }, - }) - } - - const extension = fileType.ext - const fileName = `${Date.now()}.${extension}` - const storagePath = `teams/${teamId}/${fileName}` - - const publicUrl = await uploadFile(buffer, storagePath, fileType.mime) - - const result = await teamsRepository.updateTeamProfilePictureUrl(publicUrl) - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - after(async () => { - try { - const currentFileName = fileName - const folderPath = `teams/${teamId}` - const files = await getFiles(folderPath) - - for (const file of files) { - const filePath = file.name - if (filePath === `${folderPath}/${currentFileName}`) { - continue - } - - await deleteFile(filePath) - } - } catch (cleanupError) { - l.warn({ - key: 'upload_team_profile_picture_action:cleanup_error', - error: serializeErrorForLog(cleanupError), - team_id: teamId, - context: { - image: image.name, - }, - }) - } - }) - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - - return result.data - }) diff --git a/src/core/server/api/routers/support.ts b/src/core/server/api/routers/support.ts index 5b3443898..5184f814e 100644 --- a/src/core/server/api/routers/support.ts +++ b/src/core/server/api/routers/support.ts @@ -5,15 +5,10 @@ import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +import { FileSchema } from '@/core/shared/schemas/file' const E2B_API_KEY_REGEX = /e2b_[a-f0-9]{40}/i -const fileSchema = z.object({ - name: z.string(), - type: z.string(), - base64: z.string(), -}) - const supportRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( createSupportRepository, @@ -28,7 +23,7 @@ export const supportRouter = createTRPCRouter({ .input( z.object({ description: z.string().min(1), - files: z.array(fileSchema).max(5).optional(), + files: z.array(FileSchema).max(5).optional(), }) ) .mutation(async ({ ctx, input }) => { diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index 03a2d6a88..a3758d06c 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -1,18 +1,52 @@ +import { TRPCError } from '@trpc/server' +import { fileTypeFromBuffer } from 'file-type' +import { revalidatePath } from 'next/cache' +import { after } from 'next/server' +import { z } from 'zod' +import { + AddTeamMemberSchema, + RemoveTeamMemberSchema, + TeamNameSchema, +} from '@/core/modules/teams/schemas' +import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' -import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { + withAuthedRequestRepository, + withTeamAuthedRequestRepository, +} from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' -import { protectedProcedure } from '@/core/server/trpc/procedures' +import { + protectedProcedure, + protectedTeamProcedure, +} from '@/core/server/trpc/procedures' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { deleteFile, getFiles, uploadFile } from '@/core/shared/clients/storage' +import { FileSchema } from '@/core/shared/schemas/file' -const teamsRepositoryProcedure = protectedProcedure.use( - withAuthedRequestRepository(createUserTeamsRepository, (teamsRepository) => ({ +const MAX_FILE_SIZE = 5 * 1024 * 1024 + +const userTeamsRepositoryProcedure = protectedProcedure.use( + withAuthedRequestRepository( + createUserTeamsRepository, + (userTeamsRepository) => ({ + userTeamsRepository, + }) + ) +) + +const teamsRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository(createTeamsRepository, (teamsRepository) => ({ teamsRepository, })) ) +const getStorageFilePath = (folderPath: string, fileName: string) => + `${folderPath}/${fileName}` + export const teamsRouter = createTRPCRouter({ - list: teamsRepositoryProcedure.query(async ({ ctx }) => { - const teamsResult = await ctx.teamsRepository.listUserTeams() + list: userTeamsRepositoryProcedure.query(async ({ ctx }) => { + const teamsResult = await ctx.userTeamsRepository.listUserTeams() if (!teamsResult.ok) { throwTRPCErrorFromRepoError(teamsResult.error) @@ -20,4 +54,148 @@ export const teamsRouter = createTRPCRouter({ return teamsResult.data }), + members: teamsRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.teamsRepository.listTeamMembers() + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + return result.data + }), + updateName: teamsRepositoryProcedure + .input( + z.object({ + name: TeamNameSchema, + }) + ) + .mutation(async ({ ctx, input }) => { + const result = await ctx.teamsRepository.updateTeamName(input.name) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + revalidatePath(`/dashboard/${input.teamSlug}/general`, 'page') + + return result.data + }), + addMember: teamsRepositoryProcedure + .input(AddTeamMemberSchema) + .mutation(async ({ ctx, input }) => { + const result = await ctx.teamsRepository.addTeamMember(input.email) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + revalidatePath(`/dashboard/${input.teamSlug}/members`, 'page') + }), + removeMember: teamsRepositoryProcedure + .input(RemoveTeamMemberSchema) + .mutation(async ({ ctx, input }) => { + const result = await ctx.teamsRepository.removeTeamMember(input.userId) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + revalidatePath(`/dashboard/${input.teamSlug}/members`, 'page') + }), + removeProfilePicture: teamsRepositoryProcedure.mutation( + async ({ ctx, input }) => { + const result = await ctx.teamsRepository.updateTeamProfilePictureUrl(null) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + after(async () => { + try { + const folderPath = `teams/${ctx.teamId}` + + const files = await getFiles(folderPath) + + for (const file of files) { + await deleteFile(getStorageFilePath(folderPath, file.name)) + } + } catch (cleanupError) { + l.warn({ + key: 'remove_team_profile_picture_trpc:cleanup_error', + error: serializeErrorForLog(cleanupError), + team_id: ctx.teamId, + }) + } + }) + + revalidatePath(`/dashboard/${input.teamSlug}/general`, 'page') + + return result.data + } + ), + uploadProfilePicture: teamsRepositoryProcedure + .input( + z.object({ + image: FileSchema, + }) + ) + .mutation(async ({ ctx, input }) => { + const allowedTypes = ['image/jpeg', 'image/png'] + if (!allowedTypes.includes(input.image.type)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'File must be JPG or PNG format', + }) + } + + const buffer = Buffer.from(input.image.base64, 'base64') + if (buffer.length > MAX_FILE_SIZE) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'File size must be less than 5MB', + }) + } + + const fileType = await fileTypeFromBuffer(buffer) + if (!fileType) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Unable to determine file type', + }) + } + + const allowedMimeTypes = ['image/jpeg', 'image/png'] + if (!allowedMimeTypes.includes(fileType.mime)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid file type. Only JPEG and PNG images are allowed. File appears to be: ${fileType.mime}`, + }) + } + + const fileName = `${Date.now()}.${fileType.ext}` + const storagePath = getStorageFilePath(`teams/${ctx.teamId}`, fileName) + const publicUrl = await uploadFile(buffer, storagePath, fileType.mime) + + const result = + await ctx.teamsRepository.updateTeamProfilePictureUrl(publicUrl) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + after(async () => { + try { + const folderPath = `teams/${ctx.teamId}` + const currentFilePath = getStorageFilePath(folderPath, fileName) + const files = await getFiles(folderPath) + + for (const file of files) { + const filePath = getStorageFilePath(folderPath, file.name) + if (filePath === currentFilePath) continue + + await deleteFile(filePath) + } + } catch (cleanupError) { + l.warn({ + key: 'upload_team_profile_picture_trpc:cleanup_error', + error: serializeErrorForLog(cleanupError), + team_id: ctx.teamId, + context: { + image: input.image.name, + }, + }) + } + }) + + revalidatePath(`/dashboard/${input.teamSlug}/general`, 'page') + + return result.data + }), }) diff --git a/src/core/server/functions/team/get-team-members.ts b/src/core/server/functions/team/get-team-members.ts deleted file mode 100644 index d8b8fffd4..000000000 --- a/src/core/server/functions/team/get-team-members.ts +++ /dev/null @@ -1,33 +0,0 @@ -import 'server-only' - -import { z } from 'zod' -import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -import { - authActionClient, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' -import { toActionErrorFromRepoError } from '@/core/server/adapters/errors' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -const withTeamsRepository = withTeamAuthedRequestRepository( - createTeamsRepository, - (teamsRepository) => ({ teamsRepository }) -) - -const GetTeamMembersSchema = z.object({ - teamSlug: TeamSlugSchema, -}) - -export const getTeamMembers = authActionClient - .schema(GetTeamMembersSchema) - .metadata({ serverFunctionName: 'getTeamMembers' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ ctx }) => { - const result = await ctx.teamsRepository.listTeamMembers() - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - return result.data - }) diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 020cd4adf..0b0e6f649 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -660,6 +660,8 @@ export interface components { blockedReason: string | null isDefault: boolean limits: components['schemas']['UserTeamLimits'] + /** Format: date-time */ + createdAt: string } UserTeamsResponse: { teams: components['schemas']['UserTeam'][] diff --git a/src/core/shared/schemas/file.ts b/src/core/shared/schemas/file.ts new file mode 100644 index 000000000..f42c561b5 --- /dev/null +++ b/src/core/shared/schemas/file.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +const FileSchema = z.object({ + name: z.string(), + type: z.string(), + base64: z.string(), +}) + +export { FileSchema } diff --git a/src/features/dashboard/billing/invoices.tsx b/src/features/dashboard/billing/invoices.tsx index f561445ce..f22979719 100644 --- a/src/features/dashboard/billing/invoices.tsx +++ b/src/features/dashboard/billing/invoices.tsx @@ -11,8 +11,6 @@ import { InvoiceIcon, } from '@/ui/primitives/icons' import { Label } from '@/ui/primitives/label' -import { Loader } from '@/ui/primitives/loader' -import { Skeleton } from '@/ui/primitives/skeleton' import { Table, TableBody, @@ -20,6 +18,7 @@ import { TableEmptyState, TableHead, TableHeader, + TableLoadingState, TableRow, } from '@/ui/primitives/table' import { useInvoices } from './hooks' @@ -64,30 +63,6 @@ function InvoicesEmpty({ error }: InvoicesEmptyProps) { ) } -function InvoicesLoading() { - return ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - - {index === 1 && ( - <> - - - Loading invoices - - - )} -
- ))} -
- ) -} - export default function BillingInvoicesTable() { const { invoices, isLoading, error } = useInvoices() @@ -124,13 +99,8 @@ export default function BillingInvoicesTable() { {showLoader && ( - - - - - + )} - {showEmpty && } {hasData && diff --git a/src/features/dashboard/layouts/page.tsx b/src/features/dashboard/layouts/page.tsx index ab28a6947..505068d3d 100644 --- a/src/features/dashboard/layouts/page.tsx +++ b/src/features/dashboard/layouts/page.tsx @@ -7,7 +7,7 @@ interface PageProps { } export const Page = ({ children, className }: PageProps) => ( -
+
{children}
) diff --git a/src/features/dashboard/layouts/wrapper.tsx b/src/features/dashboard/layouts/wrapper.tsx index 2c6c4d078..4b723e047 100644 --- a/src/features/dashboard/layouts/wrapper.tsx +++ b/src/features/dashboard/layouts/wrapper.tsx @@ -11,7 +11,7 @@ export function DefaultDashboardLayout({ }) { return (
-
+
{ 'use no memo' const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() const { toast } = useToast() const form = useForm({ @@ -49,21 +51,26 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { }, }) - const { execute, isExecuting } = useAction(addTeamMemberAction, { - onSuccess: () => { - toast(defaultSuccessToast('The member has been added to the team.')) - form.reset() - onSuccess?.() - }, - onError: ({ error }) => { - toast(defaultErrorToast(error.serverError || 'An error occurred.')) - }, - }) + const addMemberMutation = useMutation( + trpc.teams.addMember.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.members.queryKey({ teamSlug: team.slug }), + }) + toast(defaultSuccessToast('The member has been added to the team.')) + form.reset() + onSuccess?.() + }, + onError: (error) => { + toast(defaultErrorToast(error.message || 'An error occurred.')) + }, + }) + ) const onSubmit = (data: AddMemberForm) => { if (!team) return - execute({ + addMemberMutation.mutate({ teamSlug: team.slug, email: data.email, }) @@ -95,7 +102,7 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { /> - - - - - ) -} diff --git a/src/features/dashboard/settings/general/profile-picture-card.tsx b/src/features/dashboard/settings/general/profile-picture-card.tsx deleted file mode 100644 index e6193d522..000000000 --- a/src/features/dashboard/settings/general/profile-picture-card.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'use client' - -import { useQueryClient } from '@tanstack/react-query' -import { AnimatePresence, motion } from 'framer-motion' -import { ChevronsUp, ImagePlusIcon, Loader2, Pencil } from 'lucide-react' -import { useAction } from 'next-safe-action/hooks' -import { useRef, useState } from 'react' -import { USER_MESSAGES } from '@/configs/user-messages' -import { uploadTeamProfilePictureAction } from '@/core/server/actions/team-actions' -import { useDashboard } from '@/features/dashboard/context' -import { - defaultErrorToast, - defaultSuccessToast, - useToast, -} from '@/lib/hooks/use-toast' -import { cn, exponentialSmoothing } from '@/lib/utils' -import { useTRPC } from '@/trpc/client' -import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' -import { Badge } from '@/ui/primitives/badge' -import { cardVariants } from '@/ui/primitives/card' - -interface ProfilePictureCardProps { - className?: string -} - -export function ProfilePictureCard({ className }: ProfilePictureCardProps) { - const { team } = useDashboard() - const trpc = useTRPC() - const queryClient = useQueryClient() - const { toast } = useToast() - const fileInputRef = useRef(null) - const [isHovered, setIsHovered] = useState(false) - - const { execute: uploadProfilePicture, isExecuting: isUploading } = useAction( - uploadTeamProfilePictureAction, - { - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: trpc.teams.list.queryKey(), - }) - toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message)) - }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors.image) { - toast(defaultErrorToast(error.validationErrors.fieldErrors.image[0])) - return - } - - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateLogo.message - ) - ) - }, - onSettled: () => { - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - }, - } - ) - - const handleUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB in bytes - - if (file.size > MAX_FILE_SIZE) { - toast( - defaultErrorToast( - `Profile picture must be less than ${MAX_FILE_SIZE / (1024 * 1024)}MB.` - ) - ) - - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - return - } - - uploadProfilePicture({ - teamSlug: team.slug, - image: file, - }) - } - } - - const handleAvatarClick = () => { - fileInputRef.current?.click() - } - - return ( - <> - - - - ) -} diff --git a/src/features/dashboard/settings/general/remove-photo-dialog.tsx b/src/features/dashboard/settings/general/remove-photo-dialog.tsx new file mode 100644 index 000000000..2ae1a6c4c --- /dev/null +++ b/src/features/dashboard/settings/general/remove-photo-dialog.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/ui/primitives/dialog' +import { TrashIcon } from '@/ui/primitives/icons' + +interface RemovePhotoDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + isRemoving: boolean +} + +export const RemovePhotoDialog = ({ + open, + onOpenChange, + onConfirm, + isRemoving, +}: RemovePhotoDialogProps) => ( + + +
+
+ Remove profile photo? + + It will be replaced by a default one + +
+
+ + +
+
+
+
+) diff --git a/src/features/dashboard/settings/general/team-avatar.tsx b/src/features/dashboard/settings/general/team-avatar.tsx new file mode 100644 index 000000000..0bb505ed7 --- /dev/null +++ b/src/features/dashboard/settings/general/team-avatar.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { type ReactElement, useRef, useState } from 'react' +import { USER_MESSAGES } from '@/configs/user-messages' +import { useDashboard } from '@/features/dashboard/context' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Avatar, AvatarImage, PatternAvatar } from '@/ui/primitives/avatar' +import { Button } from '@/ui/primitives/button' +import { EditIcon, PhotoIcon, TrashIcon } from '@/ui/primitives/icons' +import { RemovePhotoDialog } from './remove-photo-dialog' + +const MAX_PROFILE_PICTURE_SIZE_BYTES = 5 * 1024 * 1024 + +// Converts a file into a base64 payload string; example: File("logo.png") -> "iVBORw0KGgo..." +const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : null + resolve(result?.split(',')[1] ?? '') + } + reader.onerror = reject + reader.readAsDataURL(file) + }) + +export const TeamAvatar = (): ReactElement => { + const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() + const { toast } = useToast() + const fileInputRef = useRef(null) + const [removeDialogOpen, setRemoveDialogOpen] = useState(false) + const hasPhoto = Boolean(team.profilePictureUrl) + const UploadIcon = hasPhoto ? EditIcon : PhotoIcon + const uploadLabel = hasPhoto ? 'Change' : 'Add photo' + + const resetFileInput = (): void => { + if (fileInputRef.current) fileInputRef.current.value = '' + } + + const invalidateTeams = async (): Promise => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + } + + const uploadProfilePictureMutation = useMutation( + trpc.teams.uploadProfilePicture.mutationOptions({ + onSuccess: async () => { + await invalidateTeams() + toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message)) + }, + onError: (error) => { + toast( + defaultErrorToast( + error.message || USER_MESSAGES.failedUpdateLogo.message + ) + ) + }, + onSettled: resetFileInput, + }) + ) + + const removeProfilePictureMutation = useMutation( + trpc.teams.removeProfilePicture.mutationOptions({ + onSuccess: async () => { + await invalidateTeams() + setRemoveDialogOpen(false) + toast(defaultSuccessToast(USER_MESSAGES.teamLogoRemoved.message)) + }, + onError: (error) => { + toast( + defaultErrorToast( + error.message || USER_MESSAGES.failedRemoveLogo.message + ) + ) + }, + }) + ) + + const handleUpload = async ({ + target, + }: React.ChangeEvent): Promise => { + const file = target.files?.[0] + if (!file) return + + if (file.size > MAX_PROFILE_PICTURE_SIZE_BYTES) { + toast(defaultErrorToast('Profile picture must be less than 5MB.')) + resetFileInput() + return + } + + try { + const base64 = await fileToBase64(file) + uploadProfilePictureMutation.mutate({ + teamSlug: team.slug, + image: { + base64, + name: file.name, + type: file.type, + }, + }) + } catch { + toast(defaultErrorToast('Failed to read file. Please try again.')) + resetFileInput() + } + } + + const handleUploadClick = (): void => fileInputRef.current?.click() + const handleRemoveClick = (): void => setRemoveDialogOpen(true) + const handleRemoveConfirm = (): void => + removeProfilePictureMutation.mutate({ teamSlug: team.slug }) + + return ( +
+ {hasPhoto ? ( + + + + ) : ( + + )} +
+ + {hasPhoto && ( + + )} +
+ + +
+ ) +} diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx new file mode 100644 index 000000000..1c63c3406 --- /dev/null +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' +import { formatDate } from '@/lib/utils/formatting' + +const InfoRow = ({ label, value }: { label: string; value: string }) => ( +
+ + {label} + + + {value} + +
+) + +export const TeamInfo = () => { + const { team } = useDashboard() + const createdAt = formatDate(new Date(team.createdAt), 'MMM d, yyyy') ?? '--' + + return ( +
+ + +
+ ) +} diff --git a/src/features/dashboard/settings/general/team-name.tsx b/src/features/dashboard/settings/general/team-name.tsx new file mode 100644 index 000000000..4be003a65 --- /dev/null +++ b/src/features/dashboard/settings/general/team-name.tsx @@ -0,0 +1,211 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + type ReactElement, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react' +import { USER_MESSAGES } from '@/configs/user-messages' +import { useDashboard } from '@/features/dashboard/context' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { getTRPCValidationMessages } from '@/lib/utils/trpc-errors' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { CheckIcon, EditIcon } from '@/ui/primitives/icons' + +const TEAM_NAME_MAX_FONT_SIZE_PX = 32 +const TEAM_NAME_MIN_FONT_SIZE_PX = 18 +const TEAM_NAME_INPUT_HEIGHT_PX = 40 + +const getValidationToastContent = (messages: string[]): ReactNode => + messages.length === 1 ? ( + messages[0] + ) : ( +
    + {messages.map((message) => ( +
  • {message}
  • + ))} +
+ ) + +export const TeamName = (): ReactElement => { + const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() + const { toast } = useToast() + const [isEditing, setIsEditing] = useState(false) + const [name, setName] = useState(team.name) + const [fontSize, setFontSize] = useState(TEAM_NAME_MAX_FONT_SIZE_PX) + const inputRef = useRef(null) + const textMeasureRef = useRef(null) + const trimmedName = name.trim() + const isSaveDisabled = !trimmedName || trimmedName === team.name + + const updateNameMutation = useMutation( + trpc.teams.updateName.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message)) + setIsEditing(false) + }, + onError: (error): void => { + const validationMessages = getTRPCValidationMessages(error) + if (validationMessages.length > 0) { + toast( + defaultErrorToast(getValidationToastContent(validationMessages)) + ) + return + } + + toast( + defaultErrorToast( + error.message || USER_MESSAGES.failedUpdateTeamName.message + ) + ) + }, + }) + ) + + const handleSubmit = (event?: React.FormEvent): void => { + event?.preventDefault() + if (updateNameMutation.isPending || isSaveDisabled) return + updateNameMutation.mutate({ teamSlug: team.slug, name: trimmedName }) + } + + const handleCancel = (): void => { + setName(team.name) + setIsEditing(false) + } + + useEffect(() => { + if (!isEditing || !inputRef.current) return + inputRef.current.focus() + const cursorPosition = inputRef.current.value.length + inputRef.current.setSelectionRange(cursorPosition, cursorPosition) + }, [isEditing]) + + useEffect(() => { + const input = inputRef.current + const textMeasure = textMeasureRef.current + if (!input || !textMeasure) return + + let frameId = 0 + + const updateFontSize = (): void => { + const availableWidth = input.clientWidth + if (!availableWidth) return + + let nextFontSize = TEAM_NAME_MAX_FONT_SIZE_PX + + textMeasure.textContent = name || ' ' + textMeasure.style.fontSize = `${nextFontSize}px` + + while ( + nextFontSize > TEAM_NAME_MIN_FONT_SIZE_PX && + textMeasure.scrollWidth > availableWidth + ) { + nextFontSize -= 1 + textMeasure.style.fontSize = `${nextFontSize}px` + } + + setFontSize((currentFontSize) => + currentFontSize === nextFontSize ? currentFontSize : nextFontSize + ) + } + + const scheduleFontSizeUpdate = (): void => { + window.cancelAnimationFrame(frameId) + frameId = window.requestAnimationFrame(updateFontSize) + } + + scheduleFontSizeUpdate() + + const resizeObserver = new ResizeObserver(scheduleFontSizeUpdate) + resizeObserver.observe(input) + + return () => { + resizeObserver.disconnect() + window.cancelAnimationFrame(frameId) + } + }, [name]) + + const handleStartEditing = (): void => { + setName(team.name) + setIsEditing(true) + } + + const handleNameChange = ({ + target, + }: React.ChangeEvent): void => setName(target.value) + + return ( +
+
+
+ + name + + + +
+
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/features/dashboard/sidebar/menu-teams.tsx b/src/features/dashboard/sidebar/menu-teams.tsx index 1a41b6159..d5ca86260 100644 --- a/src/features/dashboard/sidebar/menu-teams.tsx +++ b/src/features/dashboard/sidebar/menu-teams.tsx @@ -4,7 +4,6 @@ import { useCallback } from 'react' import { TEAM_SPECIFIC_RESOURCE_SEGMENTS } from '@/configs/urls' import type { TeamModel } from '@/core/modules/teams/models' import { getTeamDisplayName } from '@/core/modules/teams/utils' -import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { DropdownMenuItem, DropdownMenuLabel, @@ -12,6 +11,7 @@ import { DropdownMenuRadioItem, } from '@/ui/primitives/dropdown-menu' import { useDashboard } from '../context' +import { TeamAvatar } from './team-avatar' const PRESERVED_SEARCH_PARAMS = ['tab'] as const @@ -67,12 +67,7 @@ export default function DashboardSidebarMenuTeams() { teams.map((team) => ( - - - - {team.name?.charAt(0).toUpperCase() || '?'} - - + {getTeamDisplayName(team)} diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx index 8beb64593..88c20b9ce 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -7,7 +7,6 @@ import { PROTECTED_URLS } from '@/configs/urls' import { getTeamDisplayName } from '@/core/modules/teams/utils' import { signOutAction } from '@/core/server/actions/auth-actions' import { cn } from '@/lib/utils' -import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { DropdownMenu, DropdownMenuContent, @@ -20,6 +19,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/ui/primitives/sidebar' import { useDashboard } from '../context' import { CreateTeamDialog } from './create-team-dialog' import DashboardSidebarMenuTeams from './menu-teams' +import { TeamAvatar } from './team-avatar' interface DashboardSidebarMenuProps { className?: string @@ -49,7 +49,8 @@ export default function DashboardSidebarMenu({ className )} > - - - - {team.name?.charAt(0).toUpperCase() || '?'} - - + imageClassName="group-data-[collapsible=icon]:size-full" + />
TEAM diff --git a/src/features/dashboard/sidebar/team-avatar.tsx b/src/features/dashboard/sidebar/team-avatar.tsx new file mode 100644 index 000000000..558815f38 --- /dev/null +++ b/src/features/dashboard/sidebar/team-avatar.tsx @@ -0,0 +1,34 @@ +import type { TeamModel } from '@/core/modules/teams/models' +import { cn } from '@/lib/utils' +import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' + +interface TeamAvatarProps { + team: TeamModel + className?: string + imageClassName?: string +} + +export const TeamAvatar = ({ + team, + className, + imageClassName, +}: TeamAvatarProps) => { + if (!team.profilePictureUrl) { + return ( + + + {team.name?.charAt(0).toUpperCase() || '?'} + + + ) + } + + return ( + + + + ) +} diff --git a/src/lib/utils/trpc-errors.ts b/src/lib/utils/trpc-errors.ts index 0ef73764b..f08ced663 100644 --- a/src/lib/utils/trpc-errors.ts +++ b/src/lib/utils/trpc-errors.ts @@ -1,11 +1,26 @@ import { TRPCClientError, type TRPCClientErrorLike } from '@trpc/client' +import { z } from 'zod' import type { TRPCAppRouter } from '@/core/server/api/routers' -export function isNotFoundError( +const TrpcErrorWithZodDataSchema = z.object({ + data: z + .object({ + zodError: z + .object({ + formErrors: z.array(z.string()), + fieldErrors: z.record(z.string(), z.array(z.string()).optional()), + }) + .nullable() + .optional(), + }) + .optional(), +}) + +const isNotFoundError = ( error: unknown ): error is | TRPCClientErrorLike - | TRPCClientError { + | TRPCClientError => { if (error instanceof TRPCClientError) { return error.data?.code === 'NOT_FOUND' } @@ -24,3 +39,17 @@ export function isNotFoundError( trpcLikeError.shape?.data?.code === 'NOT_FOUND' ) } + +const getTRPCValidationMessages = (error: unknown): string[] => { + const parsedError = TrpcErrorWithZodDataSchema.safeParse(error) + if (!parsedError.success || !parsedError.data.data?.zodError) return [] + + const { formErrors, fieldErrors } = parsedError.data.data.zodError + + return [ + ...formErrors, + ...Object.values(fieldErrors).flatMap((messages) => messages ?? []), + ] +} + +export { getTRPCValidationMessages, isNotFoundError } diff --git a/src/ui/primitives/avatar.tsx b/src/ui/primitives/avatar.tsx index c70e9049b..d0ad3c247 100644 --- a/src/ui/primitives/avatar.tsx +++ b/src/ui/primitives/avatar.tsx @@ -2,6 +2,7 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar' import * as React from 'react' +import { useId } from 'react' import { cn } from '@/lib/utils' const Avatar = React.forwardRef< @@ -49,4 +50,91 @@ const AvatarFallback = React.forwardRef< )) AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName -export { Avatar, AvatarFallback, AvatarImage } +const PATTERN_COLUMN_COUNT = 16 +const PATTERN_ROW_COUNT = 16 +const PATTERN_CELL_SIZE = 8 +const PATTERN_FONT_SIZE = 10 + +const patternCells = Array.from( + { length: PATTERN_ROW_COUNT * PATTERN_COLUMN_COUNT }, + (_, index) => { + const row = Math.floor(index / PATTERN_COLUMN_COUNT) + const col = index % PATTERN_COLUMN_COUNT + const glyph = (row + col * 2) % 5 === 0 ? '-' : '*' + const isAccent = (row * 5 + col * 3) % 11 === 0 + + return { + col, + glyph, + isAccent, + row, + x: 8 + col * PATTERN_CELL_SIZE, + y: 14 + row * PATTERN_CELL_SIZE, + } + } +) + +interface PatternAvatarProps { + className?: string + letter: string +} + +const PatternAvatar = ({ className, letter }: PatternAvatarProps) => { + const clipPathId = useId().replaceAll(':', '') + const normalizedLetter = letter.trim().charAt(0).toUpperCase() || '?' + + return ( +
+ +
+ ) +} + +export { Avatar, AvatarFallback, AvatarImage, PatternAvatar } diff --git a/src/ui/primitives/loader.tsx b/src/ui/primitives/loader.tsx index 0fbb58cb5..5a9fccd3d 100644 --- a/src/ui/primitives/loader.tsx +++ b/src/ui/primitives/loader.tsx @@ -1,3 +1,5 @@ +'use client' + import * as React from 'react' import styled, { css } from 'styled-components' diff --git a/src/ui/primitives/table.tsx b/src/ui/primitives/table.tsx index 67bdc1eb9..b75848ace 100644 --- a/src/ui/primitives/table.tsx +++ b/src/ui/primitives/table.tsx @@ -1,6 +1,8 @@ import * as React from 'react' import { cn } from '@/lib/utils' +import { Loader } from './loader' +import { Skeleton } from './skeleton' import { TableEmptyRowBorder } from './table-empty-row-border' const Table = React.forwardRef< @@ -135,6 +137,13 @@ interface TableEmptyStateProps { className?: string } +interface TableLoadingStateProps { + colSpan: number + label: string + rowCount?: number + className?: string +} + const EMPTY_STATE_ROWS = Array.from({ length: 3 }) const TableEmptyState = ({ @@ -165,6 +174,42 @@ const TableEmptyState = ({ ) +const TableLoadingState = ({ + colSpan, + label, + rowCount = 3, + className, +}: TableLoadingStateProps) => ( + + +
+ {Array.from({ length: rowCount }).map((_, index) => ( +
+ + + {index === Math.floor(rowCount / 2) ? ( + <> + + + {label} + + + ) : null} +
+ ))} +
+
+
+) + export { Table, TableBody, @@ -174,5 +219,6 @@ export { TableFooter, TableHead, TableHeader, + TableLoadingState, TableRow, }