Skip to content

Commit

Permalink
Closes #70: improve profile page (#107)
Browse files Browse the repository at this point in the history
  • Loading branch information
manekenpix authored Aug 3, 2024
1 parent d5d7354 commit bec0bf5
Show file tree
Hide file tree
Showing 15 changed files with 216 additions and 65 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "mime_type" TEXT NOT NULL DEFAULT '';
7 changes: 4 additions & 3 deletions api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ model Comment {
}

model Image {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @unique
image Bytes
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @unique
image Bytes
mimetype String @map("mime_type") @default("")
}

enum Role {
Expand Down
23 changes: 15 additions & 8 deletions api/src/image/daos/image.dao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,31 @@ import { PrismaService } from '../../prisma/prisma.service';
export class ImageDao {
constructor(private readonly prisma: PrismaService) {}

getImage(filename: string) {
getImage(username: string) {
return this.prisma.image.findUniqueOrThrow({
where: {
name: filename,
name: username,
},
});
}

createImage(filename: string, image: Buffer) {
return this.prisma.image.create({
data: { name: filename, image },
createImage(username: string, mimetype: string, image: Buffer) {
return this.prisma.image.upsert({
where: {
name: username,
},
update: {
mimetype,
image,
},
create: { name: username, image, mimetype },
});
}

async deleteImage(filename: string) {
return await this.prisma.image.delete({
deleteImage(username: string) {
this.prisma.image.delete({
where: {
name: filename,
name: username,
},
});
}
Expand Down
4 changes: 2 additions & 2 deletions api/src/image/image.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export class ImageController {
@Public()
@Get('/:name')
async getImage(@Param('name') name: string, @Res() res: Response) {
const image = await this.imageService.getImage(name);
const { image, mimetype } = await this.imageService.getImage(name);

res.end(image);
res.contentType(mimetype).end(image);
}
}
20 changes: 7 additions & 13 deletions api/src/image/image.service.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ImageDao } from './daos/image.dao';

@Injectable()
export class ImageService {
constructor(private imageDao: ImageDao) {}

async getImage(filename: string) {
const { image } = await this.imageDao.getImage(filename);

if (!image) {
throw new NotFoundException('No image found');
}

return image;
async getImage(username: string) {
return this.imageDao.getImage(username);
}

async createImage(filename: string, image: Buffer) {
return this.imageDao.createImage(filename, image);
async createImage(username: string, mimetype: string, image: Buffer) {
return this.imageDao.createImage(username, mimetype, image);
}

async deleteImage(filename: string) {
return await this.imageDao.deleteImage(filename);
async deleteImage(username: string) {
return this.imageDao.deleteImage(username);
}
}
9 changes: 7 additions & 2 deletions api/src/user/user.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,13 @@ describe('UserController', () => {
},
};
fakeImageService = {
async getImage(name: string) {
return Buffer.from(name);
getImage: (name: string) => {
return Promise.resolve({
id: '1234',
name,
image: Buffer.from(name),
mimetype: 'image/jpeg',
});
},
};

Expand Down
22 changes: 7 additions & 15 deletions api/src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { QueryDto } from './dto/query.dto';
import { RemoveFieldsInterceptor } from './interceptor/remove-fields.interceptor';
import { FriendStatus } from './user.service';
import { ImageService } from '../image/image.service';
import { AVATAR_MAX_SIZE } from 'src/utils';

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
Expand Down Expand Up @@ -108,14 +109,14 @@ export class UserController {
}

@UseInterceptors(RemoveFieldsInterceptor)
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.CREATED)
@Post('/avatar')
@UseInterceptors(FileInterceptor('image'))
async createAvatar(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
new MaxFileSizeValidator({ maxSize: AVATAR_MAX_SIZE }),
new FileTypeValidator({ fileType: '.(png|jpeg|gif)' }),
],
}),
Expand All @@ -125,27 +126,18 @@ export class UserController {
) {
if (!req.user) throw new UnauthorizedException('user not found');

const extension = file.originalname
.split('.')
.slice(-1) as unknown as string;
const { username } = req.user;
const avatarName = `${username}.${extension}`;

if (req.user.avatarName) {
this.imageService.deleteImage(req.user.avatarName);
}

this.userService.updateUser(req.user.id, { avatarName });
const { mimetype } = file;

return this.imageService.createImage(avatarName, file?.buffer);
return this.imageService.createImage(username, mimetype, file?.buffer);
}

@UseInterceptors(RemoveFieldsInterceptor)
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@Delete('/avatar')
deleteAvatar(@Req() req: Request) {
async deleteAvatar(@Req() req: Request) {
if (!req.user) throw new UnauthorizedException('user not found');
return this.imageService.deleteImage(req.user?.username);
await this.imageService.deleteImage(req.user?.username);
}
}
1 change: 1 addition & 0 deletions api/src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const PER_PAGE = 500;
export const PAGE_NUMBER = 0;
export const AVATAR_MAX_SIZE = 5 * 1024 * 1024;
8 changes: 8 additions & 0 deletions web/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,11 @@ export type ToastType = {
dismissible: boolean;
timeout: number;
};

export type Stats = {
id?: string;
listCount: number;
moviesCount: number;
sharedListCount: number;
commentsCount: number;
};
32 changes: 23 additions & 9 deletions web/src/lib/Avatar.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import { onMount } from 'svelte';
import { PROFILE_IMAGE_PLACEHOLDER } from '../utils';
export let username: string;
export let isLarge: Boolean = false;
Expand All @@ -9,13 +10,22 @@
let failed = false;
let loading = false;
const imageClass = `inline-block text-center rounded-full bg-accent text-text select-none ${
isLarge ? 'h-24 w-24 text-8xl' : 'h-6 w-6 has-tooltip'
const avatarBaseClass =
'inline-block text-center rounded-full bg-accent text-text select-none';
const imageClass = `${avatarBaseClass} ${
isLarge ? 'h-56 w-56 text-8xl content-center' : 'h-6 w-6 has-tooltip'
}`;
const initialClass = `${avatarBaseClass} ${
isLarge ? 'w-64 h-64 text-8xl content-center ' : 'h-6 w-6 has-tooltip'
}`;
const fullPath = `${env.PUBLIC_API_HOST || 'http://localhost:4000'}/images/${username}`;
let img: HTMLImageElement;
onMount(() => {
const img = new Image();
img = new Image();
img.src = fullPath;
loading = true;
Expand All @@ -33,12 +43,16 @@
{#if loaded}
<img class="{imageClass}" src="{fullPath}" alt="Avatar" />
{:else if failed}
<div class="{imageClass}">
<span class="tooltip rounded-lg bg-background text-text p-2 mt-5 -ml-5"
>{username}</span
>
{initial}
</div>
{#if isLarge}
<img class="{imageClass}" src="{PROFILE_IMAGE_PLACEHOLDER}" alt="Avatar" />
{:else}
<div class="{initialClass}">
<span class="tooltip rounded-lg bg-background text-text p-2 mt-5 -ml-5"
>{username}</span
>
{initial}
</div>
{/if}
{:else if loading}
<img
class="{imageClass}"
Expand Down
69 changes: 69 additions & 0 deletions web/src/routes/user/profile/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { API_HOST } from '$env/static/private';
import { fail } from '@sveltejs/kit';
import type { Stats } from '../../../ambient';
import type { RequestEvent } from '../$types';

const API = process.env.API_HOST || API_HOST || 'http://localhost:4000';

/** @type {import('./$types').PageServerLoad} */
export const load = async ({ fetch, locals }) => {
const { user } = locals;

if (!user) {
return {};
}

try {
const userStats = await fetch(`${API}/users/${user.id}/stats`, {
method: 'GET',
headers: {
Authorization: locals.cookie as string
}
});

if (userStats.status !== 200) {
return { user: user };
}

const stats: Stats = await userStats.json();

return { user, stats };
} catch (e) {
console.error(e);
}
};

/** @type {import('./$types').Actions} */
export const actions = {
saveAvatar: async ({ request, fetch, locals }: RequestEvent) => {
const data = await request.formData();
const avatar = data.get('file') as File;

if (!(avatar as File).name || (avatar as File).name === 'undefined') {
return fail(400, {
error: true,
message: 'You must provide a file to upload'
});
}

const image = new Blob([await avatar.arrayBuffer()], { type: avatar.type });
const formData = new FormData();
formData.set('image', image, avatar.name);

try {
const response = await fetch(`${API}/users/avatar`, {
method: 'POST',
headers: {
Authorization: locals.cookie as string
},
body: formData
});

if (response.status !== 201) {
return fail(response.status);
}
} catch (e) {
console.error(e);
}
}
};
65 changes: 52 additions & 13 deletions web/src/routes/user/profile/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,25 +1,64 @@
<script lang="ts">
/** @type {import('./$types').PageData} */
import { enhance, applyAction } from '$app/forms';
import Avatar from '$lib/Avatar.svelte';
import Nav from '$lib/Nav/Nav.svelte';
import type { User } from '../../../ambient';
import Toasts from '$lib/Toast/Toasts.svelte';
import { addToast } from '../../../store';
import { error, success } from './messages';
export let data: {
user: User;
export let data;
const { user, stats } = data;
let trigger = {};
const reload = () => {
trigger = {};
};
const { user } = data;
</script>

<Toasts />

<Nav username="{user.username}" />
<div class="flex max-w-4xl mt-20">
<div class="w-1/5 mx-auto">
<div class="m-auto text-center">
<div class="sm:flex w-full justify-around mt-20 mx-auto">
<div class="max-w-64 text-center mx-auto mb-8 sm:mb-0">
{#key trigger}
<Avatar username="{user.username}" isLarge />
<div class="mt-2">{user.username}</div>
</div>
{/key}
<form
method="POST"
action="?/saveAvatar"
use:enhance="{() => {
return async ({ result }) => {
if (result.type === 'failure') {
addToast(error);
} else if (result.type === 'success') {
addToast(success);
reload();
}
await applyAction(result);
};
}}"
enctype="multipart/form-data"
>
<input
type="file"
name="file"
accept="image/jpg, image/png"
class="mt-4 p-0 file:mr-2 file:py-2 file:px-2
file:rounded-full file:border-0
file:text-sm file:bg-primary max-w-64"
required
/>
<button type="submit" class="mt-4">Upload</button>
</form>
</div>
<div class="flex-grow pl-2 pt-2">
<div class="mb-2">email: {user.email}</div>
<div class="mb-2">lists: N/A</div>
<div class="mb-2">watched movies: N/A</div>
<div
class="max-w-md sm:max-w-xl mx-auto text-xl sm:text-2xl grid content-evenly pl-8 sm:pl-0"
>
<div>email: {user.email}</div>
<div>lists: {stats.listCount}</div>
<div>movies: {stats.moviesCount}</div>
<div>shared with you: {stats.sharedListCount}</div>
<div>comments: {stats.commentsCount}</div>
</div>
</div>
Loading

0 comments on commit bec0bf5

Please sign in to comment.