Skip to content

Commit

Permalink
update docker and add upload avatar feature
Browse files Browse the repository at this point in the history
  • Loading branch information
quentingrchr committed Oct 25, 2024
1 parent 1109f4b commit 9bc62d3
Show file tree
Hide file tree
Showing 11 changed files with 355 additions and 178 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ next.config.original.js

#Content Layer
.contentlayer

#Uploads
/uploads
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 116 additions & 4 deletions src/actions/update-team-info.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Team>,
updatedTeamInfo: FormData,
teamId: string
): Promise<UpdateTeamInfoResult> {
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),
Expand Down
32 changes: 19 additions & 13 deletions src/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,32 +9,37 @@ interface IProps {
}

export default function Avatar({ size = 'md', src, name }: IProps) {
const [isImageError, setIsImageError] = useState(false);
const sizeClass: Record<IProps['size'], string> = {
'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<IProps['size'], number> = {
'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 (
<Image
className={`inline-block rounded-full ${sizeClass[size]}`}
src={src}
quality={100}
width={sizePixels[size]}
height={sizePixels[size]}
alt="avatar"
onError={(e) => {
setIsImageError(true);
}}
/>
);
}
Expand Down
96 changes: 61 additions & 35 deletions src/components/teams/form/settings/AvatarField.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);

function onAvatarChange(file: File) {}
function onAvatarRemove() {}
const [file, setFile] = useState<File | null>(null);
const { control, setValue } = useFormContext();

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
fileInputRef.current?.click();
};

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
onAvatarChange?.(file);
}
};

const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
onAvatarRemove?.();
if (fileInputRef.current) {
fileInputRef.current.value = '';
const handleFileChange = (
event: ChangeEvent<HTMLInputElement>,
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 (
<div className="flex flex-col gap-2 ">
<label htmlFor="avatar" className="font-semibold">
Avatar
</label>
<p className="font-semibold">Avatar</p>
<fieldset className="flex gap-4">
<Avatar size="2xl" src="/og-cover.png" name={name} />
<div className="flex flex-col items-center justify-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
aria-label="Upload avatar"
/>
<Button variant="outline" onClick={handleClick}>
Change
</Button>
<Button variant="destructiveGhost">Remove</Button>
</div>
<Controller
name="avatar-upload"
control={control}
defaultValue={null}
render={({ field: { onChange, value } }) => (
<>
<Avatar
size="2xl"
// Display preview or fallback avatar
src={file ? URL.createObjectURL(file) : avatar}
name={name}
/>
<div className="flex flex-col items-center justify-center gap-2">
<label htmlFor="avatar-upload" className="sr-only">
Avatar
</label>
<input
ref={fileInputRef}
type="file"
id="avatar-upload"
accept="image/*"
onChange={(e) => handleFileChange(e, onChange)} // Pass onChange from Controller
className="hidden"
aria-label="Upload avatar"
/>
<Button variant="outline" onClick={handleClick}>
Change
</Button>
<Button
variant="destructiveGhost"
onClick={(e) => {
e.preventDefault();
setValue(FIELDS.avatarUpload, null);
setValue(FIELDS.avatar, null);
setFile(null);
}}
>
Remove
</Button>
</div>
</>
)}
/>
</fieldset>
</div>
);
Expand Down
Loading

0 comments on commit 9bc62d3

Please sign in to comment.