From 9bc62d3a1d42394067b36eb1a1cff0bf6cfc624a Mon Sep 17 00:00:00 2001 From: quentingrchr Date: Fri, 25 Oct 2024 18:40:17 +0200 Subject: [PATCH] update docker and add upload avatar feature --- .gitignore | 3 + docker-compose.yml | 20 +++ next.config.js | 12 ++ package.json | 1 + src/actions/update-team-info.ts | 120 +++++++++++++++- src/components/Avatar.tsx | 32 +++-- .../teams/form/settings/AvatarField.tsx | 96 ++++++++----- .../teams/form/settings/SettingsForm.tsx | 105 -------------- .../teams/form/settings/TeamInfo.tsx | 135 +++++++++++++++--- .../teams/form/settings/form-data.tsx | 2 + yarn.lock | 7 + 11 files changed, 355 insertions(+), 178 deletions(-) delete mode 100644 src/components/teams/form/settings/SettingsForm.tsx diff --git a/.gitignore b/.gitignore index ad4c378..863cd80 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ next.config.original.js #Content Layer .contentlayer + +#Uploads +/uploads \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 272204d..fb76988 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,26 @@ services: ports: - '1080:80' - '25:25' + + web: + image: node:18 + working_dir: /app + command: sh -c "yarn && npm run dev" # Install dependencies before starting + ports: + - "3000:3000" + volumes: + - ./uploads:/app/public/uploads # Mount local uploads to Next.js public directory + - .:/app # Mount entire project for development + - /app/node_modules # Prevent overriding node_modules + environment: + NODE_ENV: development + PORT: 3000 + env_file: + - .env # This will load environment variables from .env file + depends_on: + - postgres + volumes: postgresql: postgresql_data: + # uploads: ajouter point de volume pour uploads diff --git a/next.config.js b/next.config.js index 1aac90f..011f53a 100644 --- a/next.config.js +++ b/next.config.js @@ -12,6 +12,18 @@ const nextConfig = { serverActions: true, serverComponentsExternalPackages: ['mjml', 'mjml-react'], }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'digestclub.com', + }, + { + protocol: 'http', + hostname: 'localhost', + }, + ], + }, reactStrictMode: false, }; diff --git a/package.json b/package.json index f0b95ee..835a6f1 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "feed": "^4.2.2", "framer-motion": "^10.7.0", "husky": "^8.0.3", + "image-size": "^1.1.1", "jsonwebtoken": "^9.0.1", "lodash": "^4.17.21", "lru-cache": "^10.0.2", diff --git a/src/actions/update-team-info.ts b/src/actions/update-team-info.ts index 59319c1..ea2ae42 100644 --- a/src/actions/update-team-info.ts +++ b/src/actions/update-team-info.ts @@ -1,9 +1,11 @@ 'use server'; +import { FIELDS } from '@/components/teams/form/settings/form-data'; import db from '@/lib/db'; import * as Sentry from '@sentry/nextjs'; +import { mkdir, unlink, writeFile } from 'fs/promises'; +import { imageSize } from 'image-size'; +import { basename, extname, join } from 'path'; import { checkAuthAction, checkTeamAction, getErrorMessage } from './utils'; -import { Team } from '@prisma/client'; - interface UpdateTeamInfoResult { error?: { message: string; @@ -13,19 +15,129 @@ interface UpdateTeamInfoResult { }; } +async function updateAvatarFile( + dirPath: string, + fileName: string, + buffer: Buffer, + oldUrl?: string +) { + const path = join(dirPath, fileName); + + if (oldUrl) { + const oldFilname = basename(oldUrl); + const oldPath = join(dirPath, oldFilname); + + await unlink(oldPath); + } + + await writeFile(path, buffer); +} + +/** + * Upload an avatar file and link it to a team. If an old avatar exists, delete it. + * @param file The file to upload and link to the team + * @param teamId The team ID to link the file to + * @param oldAvatar The old avatar (path) to delete if it exists + * @returns + */ +async function updateAvatar(file: File, teamId: string, oldAvatar?: string) { + const MAX_FILE_SIZE_MB = 5; + const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + const MIN_WIDTH = 100; // minimum width and height in pixels + + if (file.size > MAX_FILE_SIZE_BYTES) { + return new Error(`File size must be less than ${MAX_FILE_SIZE_MB}MB.`); + } + + const fileExtension = extname(file.name); + const randomString = new Date().getTime().toString(); + const fileName = `${randomString}${fileExtension}`; + const dirPath = join(process.cwd(), 'uploads', teamId, 'avatar'); + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + const dimensions = imageSize(buffer); + + if (!dimensions.width || !dimensions.height) { + throw new Error('Unable to determine image dimensions.'); + } + + if (dimensions.width < MIN_WIDTH || dimensions.height < MIN_WIDTH) { + throw new Error(`Image must be at least ${MIN_WIDTH}x${MIN_WIDTH} pixels.`); + } + + if (dimensions.width !== dimensions.height) { + throw new Error('Image must be square.'); + } + + await mkdir(dirPath, { recursive: true }); + + await updateAvatarFile(dirPath, fileName, buffer, oldAvatar); + + await db.team.update({ + where: { id: teamId }, + data: { + avatar: `${process.env.NEXT_PUBLIC_PUBLIC_URL}/uploads/${teamId}/avatar/${fileName}`, // url to the avatar + }, + }); +} + export default async function updateTeamInfo( - updatedTeamInfo: Partial, + updatedTeamInfo: FormData, teamId: string ): Promise { try { await checkAuthAction(); await checkTeamAction(teamId); + const team = await db.team.findUnique({ + where: { id: teamId }, + select: { avatar: true }, + }); + + const avatarFile = updatedTeamInfo.get(FIELDS.avatarUpload) as File; + const name = updatedTeamInfo.get(FIELDS.avatar) as string | null; + const bio = updatedTeamInfo.get(FIELDS.bio) as string | null; + const website = updatedTeamInfo.get(FIELDS.website) as string | null; + const github = updatedTeamInfo.get(FIELDS.github) as string | null; + const twitter = updatedTeamInfo.get(FIELDS.twitter) as string | null; + const color = updatedTeamInfo.get(FIELDS.color) as string | null; + const prompt = updatedTeamInfo.get(FIELDS.prompt) as string | null; + + const updatedFields: any = {}; + + if (name) { + updatedFields['name'] = name; + } + if (bio) { + updatedFields['bio'] = bio; + } + if (website) { + updatedFields['website'] = website; + } + if (github) { + updatedFields['github'] = github; + } + if (twitter) { + updatedFields['twitter'] = twitter; + } + if (color) { + updatedFields['color'] = color; + } + if (prompt) { + updatedFields['prompt'] = prompt; + } + const updatedTeam = await db.team.update({ where: { id: teamId }, - data: updatedTeamInfo, + data: { + ...updatedFields, + }, }); + if (avatarFile) { + await updateAvatar(avatarFile, teamId, team?.avatar ?? undefined); + } + return { data: { team: JSON.stringify(updatedTeam), diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 06643e7..020d0ed 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -1,5 +1,6 @@ import cn from 'classnames'; import Image from 'next/image'; +import { useState } from 'react'; interface IProps { size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; @@ -8,32 +9,37 @@ interface IProps { } export default function Avatar({ size = 'md', src, name }: IProps) { + const [isImageError, setIsImageError] = useState(false); const sizeClass: Record = { - 'xs': 'h-4 w-4', - 'sm': 'h-6 w-6', - 'md': 'h-8 w-8', - 'lg': 'h-10 w-10', - 'xl': 'h-16 w-16', - '2xl': 'h-32 w-32', + 'xs': 'h-4 w-4' /* 16px */, + 'sm': 'h-6 w-6' /* 24px */, + 'md': 'h-8 w-8' /* 32px */, + 'lg': 'h-10 w-10' /* 40px */, + 'xl': 'h-16 w-16' /* 64px */, + '2xl': 'h-32 w-32' /* 128px */, }; const sizePixels: Record = { - 'xs': 4, - 'sm': 6, - 'md': 8, - 'lg': 10, - 'xl': 16, - '2xl': 32, + 'xs': 16, + 'sm': 24, + 'md': 32, + 'lg': 40, + 'xl': 64, + '2xl': 128, }; - if (src !== undefined) { + if (src !== undefined && !isImageError) { return ( avatar { + setIsImageError(true); + }} /> ); } diff --git a/src/components/teams/form/settings/AvatarField.tsx b/src/components/teams/form/settings/AvatarField.tsx index 8383a33..8c40884 100644 --- a/src/components/teams/form/settings/AvatarField.tsx +++ b/src/components/teams/form/settings/AvatarField.tsx @@ -1,58 +1,84 @@ import Avatar from '@/components/Avatar'; import Button from '@/components/Button'; -import { useRef } from 'react'; +import { ChangeEvent, useRef, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { FIELDS } from './form-data'; interface IProps { avatar?: string; name?: string; + teamId?: string; } -export default function AvatarField({ avatar, name }: IProps) { +export default function AvatarField({ avatar, name, teamId }: IProps) { const fileInputRef = useRef(null); - - function onAvatarChange(file: File) {} - function onAvatarRemove() {} + const [file, setFile] = useState(null); + const { control, setValue } = useFormContext(); const handleClick = (e: React.MouseEvent) => { e.preventDefault(); fileInputRef.current?.click(); }; - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - onAvatarChange?.(file); - } - }; - - const handleRemove = (e: React.MouseEvent) => { - e.preventDefault(); - onAvatarRemove?.(); - if (fileInputRef.current) { - fileInputRef.current.value = ''; + const handleFileChange = ( + event: ChangeEvent, + onChange: (file: File | null) => void + ) => { + const selectedFile = event.target.files?.[0] || null; + onChange(selectedFile); // Update the form state + if (selectedFile) { + setFile(selectedFile); + } else { + setFile(null); } }; return (
- +

Avatar

- -
- - - -
+ ( + <> + +
+ + handleFileChange(e, onChange)} // Pass onChange from Controller + className="hidden" + aria-label="Upload avatar" + /> + + +
+ + )} + />
); diff --git a/src/components/teams/form/settings/SettingsForm.tsx b/src/components/teams/form/settings/SettingsForm.tsx deleted file mode 100644 index 726dd46..0000000 --- a/src/components/teams/form/settings/SettingsForm.tsx +++ /dev/null @@ -1,105 +0,0 @@ -'use client'; - -import updateTeamInfo from '@/actions/update-team-info'; -import Button from '@/components/Button'; -import useCustomToast from '@/hooks/useCustomToast'; -import useTransitionRefresh from '@/hooks/useTransitionRefresh'; -import { Team } from '@prisma/client'; -import { FormEvent, useTransition } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; -import { DangerZoneTeam } from '../DangerZone'; -import SettingsField from './SettingsField'; -import TeamColorField from './TeamColorField'; -import { FIELDS, FieldName, textFieldsData } from './form-data'; - -const PRO_FIELDS = ['prompt']; - -type SettingsForm = Record; - -const SettingsForm = ({ team }: { team: Team }) => { - const { successToast, errorToast } = useCustomToast(); - const [isPending, startTransition] = useTransition(); - - const methods = useForm({ - mode: 'onBlur', - defaultValues: { - [FIELDS.bio]: team?.bio || '', - [FIELDS.name]: team?.name || '', - [FIELDS.website]: team?.website || '', - [FIELDS.github]: team?.github || '', - [FIELDS.twitter]: team?.twitter || '', - [FIELDS.color]: team?.color || '#6d28d9', - [FIELDS.prompt]: team?.prompt || '', - }, - }); - - const { - handleSubmit, - reset, - formState: { isDirty, dirtyFields }, - } = methods; - const { refresh, isRefreshing } = useTransitionRefresh(); - - const onSubmit = (e: FormEvent) => - startTransition(async () => { - handleSubmit(async (values) => { - let changedValues: Partial = {}; - Object.keys(dirtyFields).map((key) => { - changedValues[key as FieldName] = values[key as FieldName]; - }); - const { error } = await updateTeamInfo(changedValues, team?.id); - if (error) { - errorToast(error.message); - } else { - successToast('Team info updated successfully'); - refresh(); - } - - reset({}, { keepValues: true }); - })(e); - }); - - return ( - - {/* @ts-expect-error */} -
-
-
- {textFieldsData - .filter( - (field) => - team?.subscriptionId || !PRO_FIELDS?.includes(field?.id) - ) - .map((field) => ( - - ))} - - - -
-
- -
-
- -
-
-
-
-
-
- ); -}; - -export default SettingsForm; diff --git a/src/components/teams/form/settings/TeamInfo.tsx b/src/components/teams/form/settings/TeamInfo.tsx index 35c3e33..a335d98 100644 --- a/src/components/teams/form/settings/TeamInfo.tsx +++ b/src/components/teams/form/settings/TeamInfo.tsx @@ -5,17 +5,35 @@ import Button from '@/components/Button'; import useCustomToast from '@/hooks/useCustomToast'; import useTransitionRefresh from '@/hooks/useTransitionRefresh'; import { Team } from '@prisma/client'; -import { FormEvent, useTransition } from 'react'; +import { ImGithub } from '@react-icons/all-files/im/ImGithub'; +import { ImLink } from '@react-icons/all-files/im/ImLink'; +import { ImTwitter } from '@react-icons/all-files/im/ImTwitter'; +import { FormEvent, useEffect, useTransition } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { DangerZoneTeam } from '../DangerZone'; import AvatarField from './AvatarField'; import SettingsField from './SettingsField'; import TeamColorField from './TeamColorField'; -import { FIELDS, FieldName, textFieldsData } from './form-data'; +import { FIELDS, FieldName } from './form-data'; -const PRO_FIELDS = ['prompt']; +/* Represents the form data with fields names perfectly matching the Team model */ +interface BasicSettingsForm { + [FIELDS.avatar]: string; + [FIELDS.bio]: string; + [FIELDS.name]: string; + [FIELDS.website]: string; + [FIELDS.github]: string; + [FIELDS.twitter]: string; + [FIELDS.color]: string; + [FIELDS.prompt]: string; +} -type SettingsForm = Record; +interface InternalUploadSettingsForm { + [FIELDS.avatarUpload]: File; + [FIELDS.avatarRemove]: string; +} + +interface SettingsForm extends BasicSettingsForm, InternalUploadSettingsForm {} const TeamInfo = ({ team }: { team: Team }) => { const { successToast, errorToast } = useCustomToast(); @@ -24,7 +42,7 @@ const TeamInfo = ({ team }: { team: Team }) => { const methods = useForm({ mode: 'onBlur', defaultValues: { - [FIELDS.avatar]: '', + [FIELDS.avatar]: team?.avatar || undefined, [FIELDS.bio]: team?.bio || '', [FIELDS.name]: team?.name || '', [FIELDS.website]: team?.website || '', @@ -38,18 +56,33 @@ const TeamInfo = ({ team }: { team: Team }) => { const { handleSubmit, reset, + watch, + getValues, formState: { isDirty, dirtyFields }, } = methods; const { refresh, isRefreshing } = useTransitionRefresh(); + // Watch the entire form or specific fields + const formValues = watch(); // This will watch all form values + + // Use useEffect to log form values whenever they change + useEffect(() => { + console.log('Form values changed:', formValues); + }, [formValues]); // This effect will run every time formV + const onSubmit = (e: FormEvent) => startTransition(() => { handleSubmit(async (values) => { - let changedValues: Partial = {}; + let formData = new FormData(); + + // Map to the dirty fields (fields that have been changed) and append to the form data Object.keys(dirtyFields).map((key) => { - changedValues[key as FieldName] = values[key as FieldName]; + const k = key as FieldName; + const v = values[k]; + formData.append(k, v); }); - const { error } = await updateTeamInfo(changedValues, team?.id); + + const { error } = await updateTeamInfo(formData, team?.id); if (error) { errorToast(error.message); } else { @@ -69,21 +102,81 @@ const TeamInfo = ({ team }: { team: Team }) => {
+ + + + + + } + placeholder="https://company.io" + defaultValue={team?.website || ''} + /> + + } + prefix="@" + placeholder="" + defaultValue={team?.github || ''} + /> + + } + prefix="@" + placeholder="" + defaultValue={team?.twitter || ''} /> - {textFieldsData - .filter( - (field) => - team?.subscriptionId || !PRO_FIELDS?.includes(field?.id) - ) - .map((field) => ( - - ))} + + {team?.subscriptionId && ( + + )}
diff --git a/src/components/teams/form/settings/form-data.tsx b/src/components/teams/form/settings/form-data.tsx index 465361e..33c09e5 100644 --- a/src/components/teams/form/settings/form-data.tsx +++ b/src/components/teams/form/settings/form-data.tsx @@ -6,6 +6,8 @@ import { RegisterOptions } from 'react-hook-form'; export const FIELDS = { avatar: 'avatar', + avatarUpload: 'avatar-upload', + avatarRemove: 'avatar-remove', bio: 'bio', name: 'name', website: 'website', diff --git a/yarn.lock b/yarn.lock index 97e63ce..14ac36e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6859,6 +6859,13 @@ image-size@1.0.2: dependencies: queue "6.0.2" +image-size@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.1.1.tgz#ddd67d4dc340e52ac29ce5f546a09f4e29e840ac" + integrity sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ== + dependencies: + queue "6.0.2" + imagescript@^1.2.16: version "1.2.16" resolved "https://registry.yarnpkg.com/imagescript/-/imagescript-1.2.16.tgz#2272f535816bdcbaec9da4448de5c89a488756bd"