UI & Feature update - Alpha #9
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -61,4 +61,8 @@ export const ContentService = {
|
|||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async removeAdmin(id: string): Promise<void> {
|
||||||
|
await api.delete(`/contents/${id}/admin`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user