UI & Feature update - Alpha #9

Merged
Mathis merged 22 commits from dev into prod 2026-01-14 22:40:06 +01:00
6 changed files with 152 additions and 14 deletions
Showing only changes of commit d7c2a965a0 - Show all commits

View File

@@ -21,6 +21,9 @@ import {
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard";
import { RolesGuard } from "../auth/guards/roles.guard";
import { Roles } from "../auth/decorators/roles.decorator";
import type { AuthenticatedRequest } from "../common/interfaces/request.interface"; import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
import { ContentsService } from "./contents.service"; import { ContentsService } from "./contents.service";
import { CreateContentDto } from "./dto/create-content.dto"; import { CreateContentDto } from "./dto/create-content.dto";
@@ -65,10 +68,12 @@ export class ContentsController {
} }
@Get("explore") @Get("explore")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(60) @CacheTTL(60)
@Header("Cache-Control", "public, max-age=60") @Header("Cache-Control", "public, max-age=60")
explore( explore(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("sort") sort?: "trend" | "recent", @Query("sort") sort?: "trend" | "recent",
@@ -78,7 +83,7 @@ export class ContentsController {
@Query("query") query?: string, @Query("query") query?: string,
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe) @Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
favoritesOnly?: boolean, favoritesOnly?: boolean,
@Query("userId") userId?: string, @Query("userId") userIdQuery?: string,
) { ) {
return this.contentsService.findAll({ return this.contentsService.findAll({
limit, limit,
@@ -89,42 +94,60 @@ export class ContentsController {
author, author,
query, query,
favoritesOnly, favoritesOnly,
userId, userId: userIdQuery || req.user?.sub,
}); });
} }
@Get("trends") @Get("trends")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(300) @CacheTTL(300)
@Header("Cache-Control", "public, max-age=300") @Header("Cache-Control", "public, max-age=300")
trends( trends(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) { ) {
return this.contentsService.findAll({ limit, offset, sortBy: "trend" }); return this.contentsService.findAll({
limit,
offset,
sortBy: "trend",
userId: req.user?.sub,
});
} }
@Get("recent") @Get("recent")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(60) @CacheTTL(60)
@Header("Cache-Control", "public, max-age=60") @Header("Cache-Control", "public, max-age=60")
recent( recent(
@Req() req: AuthenticatedRequest,
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
) { ) {
return this.contentsService.findAll({ limit, offset, sortBy: "recent" }); return this.contentsService.findAll({
limit,
offset,
sortBy: "recent",
userId: req.user?.sub,
});
} }
@Get(":idOrSlug") @Get(":idOrSlug")
@UseGuards(OptionalAuthGuard)
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@CacheTTL(3600) @CacheTTL(3600)
@Header("Cache-Control", "public, max-age=3600") @Header("Cache-Control", "public, max-age=3600")
async findOne( async findOne(
@Param("idOrSlug") idOrSlug: string, @Param("idOrSlug") idOrSlug: string,
@Req() req: Request, @Req() req: AuthenticatedRequest,
@Res() res: Response, @Res() res: Response,
) { ) {
const content = await this.contentsService.findOne(idOrSlug); const content = await this.contentsService.findOne(
idOrSlug,
req.user?.sub,
);
if (!content) { if (!content) {
throw new NotFoundException("Contenu non trouvé"); throw new NotFoundException("Contenu non trouvé");
} }
@@ -158,4 +181,11 @@ export class ContentsController {
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) { remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
return this.contentsService.remove(id, req.user.sub); return this.contentsService.remove(id, req.user.sub);
} }
@Delete(":id/admin")
@UseGuards(AuthGuard, RolesGuard)
@Roles("admin")
removeAdmin(@Param("id") id: string) {
return this.contentsService.removeAdmin(id);
}
} }

View File

@@ -126,7 +126,18 @@ export class ContentsService {
this.contentsRepository.count(options), this.contentsRepository.count(options),
]); ]);
return { data, totalCount }; const processedData = data.map((content) => ({
...content,
url: this.getFileUrl(content.storageKey),
author: {
...content.author,
avatarUrl: content.author?.avatarUrl
? this.getFileUrl(content.author.avatarUrl)
: null,
},
}));
return { data: processedData, totalCount };
} }
async create(userId: string, data: CreateContentDto) { async create(userId: string, data: CreateContentDto) {
@@ -162,8 +173,30 @@ export class ContentsService {
return deleted; return deleted;
} }
async findOne(idOrSlug: string) { async removeAdmin(id: string) {
return this.contentsRepository.findOne(idOrSlug); this.logger.log(`Removing content ${id} by admin`);
const deleted = await this.contentsRepository.softDeleteAdmin(id);
if (deleted) {
await this.clearContentsCache();
}
return deleted;
}
async findOne(idOrSlug: string, userId?: string) {
const content = await this.contentsRepository.findOne(idOrSlug, userId);
if (!content) return null;
return {
...content,
url: this.getFileUrl(content.storageKey),
author: {
...content.author,
avatarUrl: content.author?.avatarUrl
? this.getFileUrl(content.author.avatarUrl)
: null,
},
};
} }
generateBotHtml(content: { title: string; storageKey: string }): string { generateBotHtml(content: { title: string; storageKey: string }): string {

View File

@@ -135,11 +135,19 @@ export class ContentsRepository {
fileSize: contents.fileSize, fileSize: contents.fileSize,
views: contents.views, views: contents.views,
usageCount: contents.usageCount, usageCount: contents.usageCount,
favoritesCount: sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt, createdAt: contents.createdAt,
updatedAt: contents.updatedAt, updatedAt: contents.updatedAt,
author: { author: {
id: users.uuid, id: users.uuid,
username: users.username, username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
}, },
category: { category: {
id: categories.id, id: categories.id,
@@ -215,7 +223,7 @@ export class ContentsRepository {
}); });
} }
async findOne(idOrSlug: string) { async findOne(idOrSlug: string, userId?: string) {
const [result] = await this.databaseService.db const [result] = await this.databaseService.db
.select({ .select({
id: contents.id, id: contents.id,
@@ -227,11 +235,30 @@ export class ContentsRepository {
fileSize: contents.fileSize, fileSize: contents.fileSize,
views: contents.views, views: contents.views,
usageCount: contents.usageCount, usageCount: contents.usageCount,
favoritesCount: sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt, createdAt: contents.createdAt,
updatedAt: contents.updatedAt, updatedAt: contents.updatedAt,
userId: contents.userId, userId: contents.userId,
author: {
id: users.uuid,
username: users.username,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
name: categories.name,
slug: categories.slug,
},
}) })
.from(contents) .from(contents)
.leftJoin(users, eq(contents.userId, users.uuid))
.leftJoin(categories, eq(contents.categoryId, categories.id))
.where( .where(
and( and(
isNull(contents.deletedAt), isNull(contents.deletedAt),
@@ -240,7 +267,20 @@ export class ContentsRepository {
) )
.limit(1); .limit(1);
return result; if (!result) return null;
const tagsForContent = await this.databaseService.db
.select({
name: tags.name,
})
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(eq(contentsToTags.contentId, result.id));
return {
...result,
tags: tagsForContent.map((t) => t.name),
};
} }
async count(options: { async count(options: {
@@ -353,6 +393,15 @@ export class ContentsRepository {
return deleted; return deleted;
} }
async softDeleteAdmin(id: string) {
const [deleted] = await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return deleted;
}
async findBySlug(slug: string) { async findBySlug(slug: string) {
const [result] = await this.databaseService.db const [result] = await this.databaseService.db
.select() .select()

View File

@@ -26,9 +26,14 @@ interface ContentCardProps {
export function ContentCard({ content }: ContentCardProps) { export function ContentCard({ content }: ContentCardProps) {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const router = useRouter(); const router = useRouter();
const [isLiked, setIsLiked] = React.useState(false); const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount); const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
React.useEffect(() => {
setIsLiked(content.isLiked || false);
setLikesCount(content.favoritesCount);
}, [content.isLiked, content.favoritesCount]);
const handleLike = async (e: React.MouseEvent) => { const handleLike = async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -54,6 +59,17 @@ export function ContentCard({ content }: ContentCardProps) {
} }
}; };
const handleUse = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
await ContentService.incrementUsage(content.id);
toast.success("Mème prêt à être utilisé !");
} catch (_error) {
toast.error("Une erreur est survenue");
}
};
return ( return (
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow"> <Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3"> <CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
@@ -118,7 +134,12 @@ export function ContentCard({ content }: ContentCardProps) {
<Share2 className="h-4 w-4" /> <Share2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
<Button size="sm" variant="secondary" className="text-xs h-8"> <Button
size="sm"
variant="secondary"
className="text-xs h-8"
onClick={handleUse}
>
Utiliser Utiliser
</Button> </Button>
</div> </div>

View File

@@ -61,4 +61,8 @@ export const ContentService = {
}); });
return data; return data;
}, },
async removeAdmin(id: string): Promise<void> {
await api.delete(`/contents/${id}/admin`);
},
}; };

View File

@@ -16,6 +16,7 @@ export interface Content {
views: number; views: number;
usageCount: number; usageCount: number;
favoritesCount: number; favoritesCount: number;
isLiked?: boolean;
tags: (string | Tag)[]; tags: (string | Tag)[];
category?: Category; category?: Category;
authorId: string; authorId: string;
@@ -39,7 +40,7 @@ export interface Category {
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
data: T[]; data: T[];
total: number; totalCount: number;
limit: number; limit: number;
offset: number; offset: number;
} }