Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #70: improve profile page #107

Merged
merged 3 commits into from
Aug 3, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
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
71 changes: 71 additions & 0 deletions web/src/routes/user/profile/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// import { fail } from '@sveltejs/kit';
// import type { RequestEvent } from './$types.js';
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