26 Commits

Author SHA1 Message Date
3ffc5b6fde chore: bump version to 1.7.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m40s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m49s
CI/CD Pipeline / Déploiement en Production (push) Failing after 6s
2026-01-28 20:40:45 +01:00
5413774cf4 chore: update pnpm-lock.yaml to reflect lockfile v6, update dependencies versions, and remove redundant nested dependency details 2026-01-28 20:37:39 +01:00
e342eacc69 style: add utility class for scrollbar hiding and align content correctly in meme page layout 2026-01-28 20:35:55 +01:00
Mathis HERRIOT
60643f6aa8 chore: bump version to 1.6.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
2026-01-28 16:30:34 +01:00
Mathis HERRIOT
929dd74ec1 feat: add AudioProvider for global audio state management
- Introduced `AudioProvider` with context for managing global mute state and active video.
- Added `useAudio` hook to access AudioContext conveniently.
2026-01-28 16:30:28 +01:00
Mathis HERRIOT
87534c0596 refactor: enhance content card and layout with video handling and audio controls
- Added mute toggle for video content in content card.
- Integrated `AudioProvider` for global audio state management.
- Improved content card layout with dynamic aspect ratio support.
- Updated content list to use a masonry-style layout for better visual presentation.
2026-01-28 16:29:56 +01:00
Mathis HERRIOT
fa673d0f80 chore: bump version to 1.5.6
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m45s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m32s
2026-01-28 15:39:56 +01:00
Mathis HERRIOT
8df6d15b19 refactor: update content card and list layout for better responsiveness
- Removed unused aspect-video class from content card layout.
- Improved content list layout by switching to a responsive grid system.
2026-01-28 15:39:44 +01:00
Mathis HERRIOT
0144421f03 chore: bump version to 1.5.5
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m36s
2026-01-28 15:00:02 +01:00
Mathis HERRIOT
df9a6c6f36 refactor: update test mocks to use addOutputOptions for consistency
- Replaced `outputOptions` with `addOutputOptions` in media service spec test to align with updated FFmpeg strategy.
2026-01-28 14:59:40 +01:00
Mathis HERRIOT
15426a9e18 chore: bump version to 1.5.4
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m10s
CI/CD Pipeline / Valider frontend (push) Successful in 1m41s
CI/CD Pipeline / Valider documentation (push) Successful in 1m45s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-28 14:45:48 +01:00
Mathis HERRIOT
a28844e9b7 refactor: replace outputOptions with addOutputOptions in video processor strategy
- Updated FFmpeg command to use `addOutputOptions` for improved readability and consistency.
2026-01-28 14:45:41 +01:00
Mathis HERRIOT
ae916931f6 chore: bump version to 1.5.3
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m39s
CI/CD Pipeline / Valider frontend (push) Successful in 1m43s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m33s
2026-01-28 14:39:43 +01:00
Mathis HERRIOT
e4dc5dd10b chore: simplify FFmpeg installation in Dockerfile
- Replaced custom FFmpeg build process with `apk add --no-cache ffmpeg` for reduced complexity and faster builds.
2026-01-28 14:39:33 +01:00
Mathis HERRIOT
878c35cbcd chore: bump version to 1.5.2
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m35s
CI/CD Pipeline / Valider documentation (push) Successful in 1m42s
CI/CD Pipeline / Valider frontend (push) Successful in 1m49s
CI/CD Pipeline / Déploiement en Production (push) Failing after 18s
2026-01-28 14:32:57 +01:00
Mathis HERRIOT
8cf0036248 chore: update Dockerfile to fix FFmpeg download URL
- Replaced the FFmpeg URL with a version that supports redirection handling using `-L` flag.
2026-01-28 14:32:52 +01:00
Mathis HERRIOT
c389024f59 chore: bump version to 1.5.1
Some checks failed
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider documentation (push) Successful in 1m46s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Failing after 13s
2026-01-28 14:27:43 +01:00
Mathis HERRIOT
bbdbe58af5 feat: add FFmpeg installation to Dockerfile for media processing
- Configured Dockerfile to install FFmpeg with support for various codecs and libraries.
- Optimized build process by cleaning up temporary files post-installation.
2026-01-28 14:27:29 +01:00
Mathis HERRIOT
5951e41eb5 chore: bump version to 1.5.0
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m38s
CI/CD Pipeline / Valider frontend (push) Successful in 1m44s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 1m35s
2026-01-28 14:14:42 +01:00
Mathis HERRIOT
7442236e8d feat: add 'video' as a new value to content_type enum
- Updated backend migrations to include 'video' in the `content_type` enum.
- Synced migration metadata files to reflect the schema changes.
2026-01-28 14:14:05 +01:00
Mathis HERRIOT
3ef7292287 feat: add captions support for video uploads
- Enhanced video previews with `<track>` element for captions.
2026-01-28 14:10:59 +01:00
Mathis HERRIOT
f1a571196d feat: add video upload feature with support for validation and processing
- Introduced "video" as a new content type across backend and frontend.
- Updated validation schemas and MIME-type handling for video files.
- Implemented file size limits for videos (10 MB max) in configuration.
- Enhanced upload flow with auto-detection of file types (image, GIF, video).
- Expanded media processing to handle video files and convert them to WebM format.
2026-01-28 14:07:00 +01:00
Mathis HERRIOT
f4cd20a010 docs: add PlantUML backend architecture diagram
- Introduced a detailed PlantUML diagram illustrating backend modules, services, controllers, and key relationships.
- Enhanced documentation with visual representation of system architecture for improved understanding.
2026-01-28 14:00:46 +01:00
Mathis HERRIOT
988eacc281 docs: remove detailed table of contents from project dossier
- Simplified the document by removing the exhaustive table of contents to enhance readability.
- Maintained key subsections and updated in-text references for smooth navigation.
2026-01-28 13:19:49 +01:00
Mathis HERRIOT
329a150ff8 docs: refine and expand project dossier content
- Improved structure with updated headings and enhanced indentation for clarity.
- Added new sections: "Analyse et Conception," personas, user stories, and advanced diagrams (use cases, sequence flows).
- Expanded content on backend architecture, security features, and compliance (RGPD, ANSSI).
- Updated annexes with additional resources and technical glossary entries.
2026-01-28 13:01:10 +01:00
Mathis HERRIOT
4372f75025 docs: remove POO class diagram from annex in project dossier
- Deleted the class diagram representing backend entities to simplify and declutter the annex section.
2026-01-28 12:36:43 +01:00
27 changed files with 12629 additions and 11739 deletions

756
backend.plantuml Normal file
View File

@@ -0,0 +1,756 @@
@startuml
!theme plain
top to bottom direction
skinparam linetype ortho
class AdminController {
constructor(adminService: AdminService):
getStats(): Promise<{users: number, contents: numbe…
}
class AdminModule
class AdminService {
constructor(usersRepository: UsersRepository, contentsRepository: ContentsRepository, categoriesRepository: CategoriesRepository):
getStats(): Promise<{users: number, contents: numbe…
}
class AllExceptionsFilter {
logger: Logger
catch(exception: unknown, host: ArgumentsHost): void
}
class ApiKeysController {
constructor(apiKeysService: ApiKeysService):
create(req: AuthenticatedRequest, createApiKeyDto: CreateApiKeyDto): Promise<{name: string, key: string, exp…
findAll(req: AuthenticatedRequest): Promise<any>
revoke(req: AuthenticatedRequest, id: string): Promise<any>
}
class ApiKeysModule
class ApiKeysRepository {
constructor(databaseService: DatabaseService):
create(data: {userId: string; name: string; prefix: string; keyHash: string; expiresAt?: Date}): Promise<any>
findAll(userId: string): Promise<any>
revoke(userId: string, keyId: string): Promise<any>
findActiveByKeyHash(keyHash: string): Promise<any>
updateLastUsed(id: string): Promise<any>
}
class ApiKeysService {
constructor(apiKeysRepository: ApiKeysRepository, hashingService: HashingService):
logger: Logger
create(userId: string, name: string, expiresAt?: Date): Promise<{name: string, key: string, exp…
findAll(userId: string): Promise<any>
revoke(userId: string, keyId: string): Promise<any>
validateKey(key: string): Promise<any>
}
class AppController {
constructor(appService: AppService):
getHello(): string
}
class AppModule {
configure(consumer: MiddlewareConsumer): void
}
class AppService {
getHello(): string
}
class AuditLogInDb
class AuthController {
constructor(authService: AuthService, bootstrapService: BootstrapService, configService: ConfigService):
register(registerDto: RegisterDto): Promise<{message: string, userId: any}>
login(loginDto: LoginDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
verifyTwoFactor(verify2faDto: Verify2faDto, userAgent: string, req: Request, res: Response): Promise<Response<any, Record<string, an…
refresh(req: Request, res: Response): Promise<Response<any, Record<string, an…
logout(req: Request, res: Response): Promise<Response<any, Record<string, an…
bootstrapAdmin(token: string, username: string): Promise<{message: string}>
}
class AuthGuard {
constructor(jwtService: JwtService, configService: ConfigService):
canActivate(context: ExecutionContext): Promise<boolean>
}
class AuthModule
class AuthService {
constructor(usersService: UsersService, hashingService: HashingService, jwtService: JwtService, sessionsService: SessionsService, configService: ConfigService):
logger: Logger
generateTwoFactorSecret(userId: string): Promise<{secret: string, qrCodeDataUrl:…
enableTwoFactor(userId: string, token: string): Promise<{message: string}>
disableTwoFactor(userId: string, token: string): Promise<{message: string}>
register(dto: RegisterDto): Promise<{message: string, userId: any}>
login(dto: LoginDto, userAgent?: string, ip?: string): Promise<{message: string, requires2FA: …
verifyTwoFactorLogin(userId: string, token: string, userAgent?: string, ip?: string): Promise<{message: string, access_token:…
refresh(refreshToken: string): Promise<{access_token: string, refresh_…
logout(): Promise<{message: string}>
}
class AuthenticatedRequest {
user: {sub: string, username: string}
}
class BootstrapService {
constructor(rbacService: RbacService, usersService: UsersService, configService: ConfigService):
logger: Logger
bootstrapToken: string | null
onApplicationBootstrap(): Promise<void>
generateBootstrapToken(): void
consumeToken(token: string, username: string): Promise<{message: string}>
}
class CategoriesController {
constructor(categoriesService: CategoriesService):
findAll(): Promise<any>
findOne(id: string): Promise<any>
create(createCategoryDto: CreateCategoryDto): Promise<any>
update(id: string, updateCategoryDto: UpdateCategoryDto): Promise<any>
remove(id: string): Promise<any>
}
class CategoriesModule
class CategoriesRepository {
constructor(databaseService: DatabaseService):
findAll(): Promise<any>
countAll(): Promise<number>
findOne(id: string): Promise<any>
create(data: CreateCategoryDto & {slug: string}): Promise<any>
update(id: string, data: UpdateCategoryDto & {slug?: string; updatedAt: Date}): Promise<any>
remove(id: string): Promise<any>
}
class CategoriesService {
constructor(categoriesRepository: CategoriesRepository, cacheManager: Cache):
logger: Logger
clearCategoriesCache(): Promise<void>
findAll(): Promise<any>
findOne(id: string): Promise<any>
create(data: CreateCategoryDto): Promise<any>
update(id: string, data: UpdateCategoryDto): Promise<any>
remove(id: string): Promise<any>
}
class CategoryInDb
class ClamScanner {
scanStream(stream: Readable): Promise<{isInfected: boolean, viruses: …
}
class CommonModule
class ContentInDb
class ContentType {
MEME:
GIF:
}
class ContentsController {
constructor(contentsService: ContentsService):
create(req: AuthenticatedRequest, createContentDto: CreateContentDto): Promise<any>
getUploadUrl(req: AuthenticatedRequest, fileName: string): Promise<{url: string, key: string}>
upload(req: AuthenticatedRequest, file: Express.Multer.File, uploadContentDto: UploadContentDto): Promise<any>
explore(req: AuthenticatedRequest, limit: number, offset: number, sort?: "trend" | "recent", tag?: string, category?: string, author?: string): Promise<{data: any, totalCount: any}>
trends(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
recent(req: AuthenticatedRequest, limit: number, offset: number): Promise<{data: any, totalCount: any}>
findOne(idOrSlug: string, req: AuthenticatedRequest, res: Response): Promise<Response<any, Record<string, an…
incrementViews(id: string): Promise<void>
incrementUsage(id: string): Promise<void>
update(id: string, req: AuthenticatedRequest, updateContentDto: any): Promise<any>
remove(id: string, req: AuthenticatedRequest): Promise<any>
removeAdmin(id: string): Promise<any>
updateAdmin(id: string, updateContentDto: any): Promise<any>
}
class ContentsModule
class ContentsRepository {
constructor(databaseService: DatabaseService):
findAll(options: FindAllOptions): Promise<any>
create(data: NewContentInDb & {userId: string}, tagNames?: string[]): Promise<any>
findOne(idOrSlug: string, userId?: string): Promise<any>
count(options: {tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<number>
incrementViews(id: string): Promise<void>
incrementUsage(id: string): Promise<void>
softDelete(id: string, userId: string): Promise<any>
softDeleteAdmin(id: string): Promise<any>
update(id: string, data: Partial<typeof contents.$inferInsert>): Promise<any>
findBySlug(slug: string): Promise<any>
purgeSoftDeleted(before: Date): Promise<any>
}
class ContentsService {
constructor(contentsRepository: ContentsRepository, s3Service: IStorageService, mediaService: IMediaService, configService: ConfigService, cacheManager: Cache):
logger: Logger
clearContentsCache(): Promise<void>
getUploadUrl(userId: string, fileName: string): Promise<{url: string, key: string}>
uploadAndProcess(userId: string, file: Express.Multer.File, data: UploadContentDto): Promise<any>
findAll(options: {limit: number; offset: number; sortBy?: "trend" | "recent"; tag?: string; category?: string; author?: string; query?: string; favoritesOnly?: boolean; userId?: string}): Promise<{data: any, totalCount: any}>
create(userId: string, data: CreateContentDto): Promise<any>
incrementViews(id: string): Promise<void>
incrementUsage(id: string): Promise<void>
remove(id: string, userId: string): Promise<any>
removeAdmin(id: string): Promise<any>
updateAdmin(id: string, data: any): Promise<any>
update(id: string, userId: string, data: any): Promise<any>
findOne(idOrSlug: string, userId?: string): Promise<any>
generateBotHtml(content: {title: string; storageKey: string}): string
generateSlug(text: string): string
ensureUniqueSlug(title: string): Promise<string>
}
class CrawlerDetectionMiddleware {
logger: Logger
SUSPICIOUS_PATTERNS: RegExp[]
BOT_USER_AGENTS: RegExp[]
use(req: Request, res: Response, next: NextFunction): void
}
class CreateApiKeyDto {
name: string
expiresAt: string
}
class CreateCategoryDto {
name: string
description: string
iconUrl: string
}
class CreateContentDto {
type: "meme" | "gif"
title: string
storageKey: string
mimeType: string
fileSize: number
categoryId: string
tags: string[]
}
class CreateReportDto {
contentId: string
tagId: string
reason: "inappropriate" | "spam" | "copyright" …
description: string
}
class CryptoModule
class CryptoService {
constructor(hashingService: HashingService, jwtService: JwtService, encryptionService: EncryptionService, postQuantumService: PostQuantumService):
hashEmail(email: string): Promise<string>
hashIp(ip: string): Promise<string>
getPgpEncryptionKey(): string
hashPassword(password: string): Promise<string>
verifyPassword(password: string, hash: string): Promise<boolean>
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
verifyJwt(token: string): Promise<T>
encryptContent(content: string): Promise<string>
decryptContent(jwe: string): Promise<string>
signContent(content: string): Promise<string>
verifyContentSignature(jws: string): Promise<string>
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
}
class DatabaseModule
class DatabaseService {
constructor(configService: ConfigService):
logger: Logger
pool: Pool
db: ReturnType<typeof drizzle>
onModuleInit(): Promise<void>
onModuleDestroy(): Promise<void>
getDatabaseConnectionString(): string
}
class EncryptionService {
constructor(configService: ConfigService):
logger: Logger
jwtSecret: Uint8Array
encryptionKey: Uint8Array
encryptContent(content: string): Promise<string>
decryptContent(jwe: string): Promise<string>
signContent(content: string): Promise<string>
verifyContentSignature(jws: string): Promise<string>
getPgpEncryptionKey(): string
}
class Env
class FavoriteInDb
class FavoritesController {
constructor(favoritesService: FavoritesService):
add(req: AuthenticatedRequest, contentId: string): Promise<any>
remove(req: AuthenticatedRequest, contentId: string): Promise<any>
list(req: AuthenticatedRequest, limit: number, offset: number): Promise<any>
}
class FavoritesModule
class FavoritesRepository {
constructor(databaseService: DatabaseService):
findContentById(contentId: string): Promise<any>
add(userId: string, contentId: string): Promise<any>
remove(userId: string, contentId: string): Promise<any>
findByUserId(userId: string, limit: number, offset: number): Promise<any>
}
class FavoritesService {
constructor(favoritesRepository: FavoritesRepository):
logger: Logger
addFavorite(userId: string, contentId: string): Promise<any>
removeFavorite(userId: string, contentId: string): Promise<any>
getUserFavorites(userId: string, limit: number, offset: number): Promise<any>
}
class FindAllOptions {
limit: number
offset: number
sortBy: "trend" | "recent"
tag: string
category: string
author: string
query: string
favoritesOnly: boolean
userId: string
}
class HTTPLoggerMiddleware {
logger: Logger
use(request: Request, response: Response, next: NextFunction): void
}
class HashingService {
hashEmail(email: string): Promise<string>
hashIp(ip: string): Promise<string>
hashSha256(text: string): Promise<string>
hashPassword(password: string): Promise<string>
verifyPassword(password: string, hash: string): Promise<boolean>
}
class HealthController {
constructor(databaseService: DatabaseService, cacheManager: Cache):
check(): Promise<any>
}
class IMailService {
sendEmailValidation(email: string, token: string): Promise<void>
sendPasswordReset(email: string, token: string): Promise<void>
}
class IMediaProcessorStrategy {
canHandle(mimeType: string): boolean
process(buffer: Buffer, options?: Record<string, unknown>): Promise<MediaProcessingResult>
}
class IMediaService {
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
}
class IStorageService {
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Record<string, string>, bucketName?: string): Promise<string>
getFile(fileName: string, bucketName?: string): Promise<Readable>
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
deleteFile(fileName: string, bucketName?: string): Promise<void>
getFileInfo(fileName: string, bucketName?: string): Promise<unknown>
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
getPublicUrl(storageKey: string): string
}
class ImageProcessorStrategy {
logger: Logger
canHandle(mimeType: string): boolean
process(buffer: Buffer, options?: {format: "webp" | "avif"; resize?: {width?: number; height?: number}}): Promise<MediaProcessingResult>
}
class JwtService {
constructor(configService: ConfigService):
logger: Logger
jwtSecret: Uint8Array
generateJwt(payload: jose.JWTPayload, expiresIn?: string): Promise<string>
verifyJwt(token: string): Promise<T>
}
class LoginDto {
email: string
password: string
}
class MailModule
class MailService {
constructor(mailerService: MailerService, configService: ConfigService):
logger: Logger
domain: string
sendEmailValidation(email: string, token: string): Promise<void>
sendPasswordReset(email: string, token: string): Promise<void>
}
class MediaController {
constructor(s3Service: S3Service):
logger: Logger
getFile(path: string, res: Response): Promise<void>
}
class MediaModule
class MediaProcessingResult {
buffer: Buffer
mimeType: string
extension: string
width: number
height: number
size: number
}
class MediaProcessingResult {
buffer: Buffer
mimeType: string
extension: string
width: number
height: number
size: number
}
class MediaService {
constructor(configService: ConfigService, imageProcessor: ImageProcessorStrategy, videoProcessor: VideoProcessorStrategy):
logger: Logger
clamscan: ClamScanner | null
isClamAvInitialized: boolean
initClamScan(): Promise<void>
scanFile(buffer: Buffer, filename: string): Promise<ScanResult>
processImage(buffer: Buffer, format?: "webp" | "avif", resize?: {width?: number; height?: number}): Promise<MediaProcessingResult>
processVideo(buffer: Buffer, format?: "webm" | "av1"): Promise<MediaProcessingResult>
}
class NewAuditLogInDb
class NewCategoryInDb
class NewContentInDb
class NewFavoriteInDb
class NewReportInDb
class NewTagInDb
class NewUserInDb
class OptionalAuthGuard {
constructor(jwtService: JwtService, configService: ConfigService):
canActivate(context: ExecutionContext): Promise<boolean>
}
class PostQuantumService {
generatePostQuantumKeyPair(): {publicKey: Uint8Array<ArrayBufferLike>…
encapsulate(publicKey: Uint8Array): {cipherText: Uint8Array, sharedSecret: …
decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array<ArrayBufferLike>
}
class PurgeService {
constructor(sessionsRepository: SessionsRepository, reportsRepository: ReportsRepository, usersRepository: UsersRepository, contentsRepository: ContentsRepository):
logger: Logger
purgeExpiredData(): Promise<void>
}
class RbacRepository {
constructor(databaseService: DatabaseService):
findRolesByUserId(userId: string): Promise<any>
findPermissionsByUserId(userId: string): Promise<any[]>
countRoles(): Promise<number>
countAdmins(): Promise<number>
createRole(name: string, slug: string, description?: string): Promise<any>
assignRole(userId: string, roleSlug: string): Promise<any>
}
class RbacService {
constructor(rbacRepository: RbacRepository):
logger: Logger
onApplicationBootstrap(): Promise<void>
seedRoles(): Promise<void>
getUserRoles(userId: string): Promise<any>
getUserPermissions(userId: string): Promise<any[]>
countAdmins(): Promise<number>
assignRoleToUser(userId: string, roleSlug: string): Promise<any>
}
class RefreshDto {
refresh_token: string
}
class RegisterDto {
username: string
displayName: string
email: string
password: string
}
class ReportInDb
class ReportReason {
INAPPROPRIATE:
SPAM:
COPYRIGHT:
OTHER:
}
class ReportStatus {
PENDING:
REVIEWED:
RESOLVED:
DISMISSED:
}
class ReportsController {
constructor(reportsService: ReportsService):
create(req: AuthenticatedRequest, createReportDto: CreateReportDto): Promise<any>
findAll(limit: number, offset: number): Promise<any>
updateStatus(id: string, updateReportStatusDto: UpdateReportStatusDto): Promise<any>
}
class ReportsModule
class ReportsRepository {
constructor(databaseService: DatabaseService):
create(data: {reporterId: string; contentId?: string; tagId?: string; reason: "inappropriate" | "spam" | "copyright" | "other"; description?: string}): Promise<any>
findAll(limit: number, offset: number): Promise<any>
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
purgeObsolete(now: Date): Promise<any>
}
class ReportsService {
constructor(reportsRepository: ReportsRepository):
logger: Logger
create(reporterId: string, data: CreateReportDto): Promise<any>
findAll(limit: number, offset: number): Promise<any>
updateStatus(id: string, status: "pending" | "reviewed" | "resolved" | "dismissed"): Promise<any>
}
class RequestWithUser {
user: {sub?: string, username?: string, id?: …
}
class RolesGuard {
constructor(reflector: Reflector, rbacService: RbacService):
canActivate(context: ExecutionContext): Promise<boolean>
}
class S3Module
class S3Service {
constructor(configService: ConfigService):
logger: Logger
minioClient: Minio.Client
bucketName: string
onModuleInit(): Promise<void>
ensureBucketExists(bucketName: string): Promise<void>
uploadFile(fileName: string, file: Buffer, mimeType: string, metaData?: Minio.ItemBucketMetadata, bucketName?: string): Promise<string>
getFile(fileName: string, bucketName?: string): Promise<stream.Readable>
getFileUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
getUploadUrl(fileName: string, expiry?: number, bucketName?: string): Promise<string>
deleteFile(fileName: string, bucketName?: string): Promise<void>
getFileInfo(fileName: string, bucketName?: string): Promise<BucketItemStat>
moveFile(sourceFileName: string, destinationFileName: string, sourceBucketName?: string, destinationBucketName?: string): Promise<string>
getPublicUrl(storageKey: string): string
}
class ScanResult {
isInfected: boolean
virusName: string
}
class ScanResult {
isInfected: boolean
virusName: string
}
class SessionData {
accessToken: string
refreshToken: string
userId: string
}
class SessionsModule
class SessionsRepository {
constructor(databaseService: DatabaseService):
create(data: {userId: string; refreshToken: string; userAgent?: string; ipHash?: string | null; expiresAt: Date}): Promise<any>
findValidByRefreshToken(refreshToken: string): Promise<any>
update(sessionId: string, data: Record<string, unknown>): Promise<any>
revoke(sessionId: string): Promise<void>
revokeAllByUserId(userId: string): Promise<void>
purgeExpired(now: Date): Promise<any>
}
class SessionsService {
constructor(sessionsRepository: SessionsRepository, hashingService: HashingService, jwtService: JwtService):
createSession(userId: string, userAgent?: string, ip?: string): Promise<any>
refreshSession(oldRefreshToken: string): Promise<any>
revokeSession(sessionId: string): Promise<void>
revokeAllUserSessions(userId: string): Promise<void>
}
class TagInDb
class TagsController {
constructor(tagsService: TagsService):
findAll(limit: number, offset: number, query?: string, sort?: "popular" | "recent"): Promise<any>
}
class TagsModule
class TagsRepository {
constructor(databaseService: DatabaseService):
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
}
class TagsService {
constructor(tagsRepository: TagsRepository):
logger: Logger
findAll(options: {limit: number; offset: number; query?: string; sortBy?: "popular" | "recent"}): Promise<any>
}
class UpdateCategoryDto
class UpdateConsentDto {
termsVersion: string
privacyVersion: string
}
class UpdateReportStatusDto {
status: "pending" | "reviewed" | "resolved" | "…
}
class UpdateUserDto {
displayName: string
bio: string
avatarUrl: string
status: "active" | "verification" | "suspended"…
role: string
}
class UploadContentDto {
type: "meme" | "gif"
title: string
categoryId: string
tags: string[]
}
class UserInDb
class UsersController {
constructor(usersService: UsersService, authService: AuthService):
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
findPublicProfile(username: string): Promise<any>
findMe(req: AuthenticatedRequest): Promise<any>
exportMe(req: AuthenticatedRequest): Promise<null | {profile: any, contents:…
updateMe(req: AuthenticatedRequest, updateUserDto: UpdateUserDto): Promise<any>
updateAvatar(req: AuthenticatedRequest, file: Express.Multer.File): Promise<any>
updateConsent(req: AuthenticatedRequest, consentDto: UpdateConsentDto): Promise<any>
removeMe(req: AuthenticatedRequest): Promise<any>
removeAdmin(uuid: string): Promise<any>
updateAdmin(uuid: string, updateUserDto: UpdateUserDto): Promise<any>
setup2fa(req: AuthenticatedRequest): Promise<{secret: string, qrCodeDataUrl:…
enable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
disable2fa(req: AuthenticatedRequest, token: string): Promise<{message: string}>
}
class UsersModule
class UsersRepository {
constructor(databaseService: DatabaseService):
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
findByEmailHash(emailHash: string): Promise<any>
findOneWithPrivateData(uuid: string): Promise<any>
countAll(): Promise<number>
findAll(limit: number, offset: number): Promise<any>
findByUsername(username: string): Promise<any>
findOne(uuid: string): Promise<any>
update(uuid: string, data: Partial<typeof users.$inferInsert>): Promise<any>
getTwoFactorSecret(uuid: string): Promise<any>
getUserContents(uuid: string): Promise<any>
getUserFavorites(uuid: string): Promise<any>
softDeleteUserAndContents(uuid: string): Promise<any>
purgeDeleted(before: Date): Promise<any>
}
class UsersService {
constructor(usersRepository: UsersRepository, cacheManager: Cache, rbacService: RbacService, mediaService: IMediaService, s3Service: IStorageService):
logger: Logger
clearUserCache(username?: string): Promise<void>
create(data: {username: string; email: string; passwordHash: string; emailHash: string}): Promise<any>
findByEmailHash(emailHash: string): Promise<any>
findOneWithPrivateData(uuid: string): Promise<any>
findAll(limit: number, offset: number): Promise<{data: any, totalCount: any}>
findPublicProfile(username: string): Promise<any>
findOne(uuid: string): Promise<any>
update(uuid: string, data: UpdateUserDto): Promise<any>
updateAvatar(uuid: string, file: Express.Multer.File): Promise<any>
updateConsent(uuid: string, termsVersion: string, privacyVersion: string): Promise<any>
setTwoFactorSecret(uuid: string, secret: string): Promise<any>
toggleTwoFactor(uuid: string, enabled: boolean): Promise<any>
getTwoFactorSecret(uuid: string): Promise<string | null>
exportUserData(uuid: string): Promise<null | {profile: any, contents:…
remove(uuid: string): Promise<any>
}
class Verify2faDto {
userId: string
token: string
}
class VideoProcessorStrategy {
logger: Logger
canHandle(mimeType: string): boolean
process(buffer: Buffer, options?: {format: "webm" | "av1"}): Promise<MediaProcessingResult>
}
AdminController -[#595959,dashed]-> AdminService
AdminService -[#595959,dashed]-> CategoriesRepository
AdminService -[#595959,dashed]-> ContentsRepository
AdminService -[#595959,dashed]-> UsersRepository
AllExceptionsFilter -[#595959,dashed]-> RequestWithUser
ApiKeysController -[#595959,dashed]-> ApiKeysService
ApiKeysController -[#595959,dashed]-> AuthenticatedRequest
ApiKeysController -[#595959,dashed]-> CreateApiKeyDto
ApiKeysRepository -[#595959,dashed]-> DatabaseService
ApiKeysService -[#595959,dashed]-> ApiKeysRepository
ApiKeysService -[#595959,dashed]-> ApiKeysService
ApiKeysService -[#595959,dashed]-> HashingService
AppController -[#595959,dashed]-> AppService
AppModule -[#595959,dashed]-> CrawlerDetectionMiddleware
AppModule -[#595959,dashed]-> HTTPLoggerMiddleware
AuthController -[#595959,dashed]-> AuthService
AuthController -[#595959,dashed]-> BootstrapService
AuthController -[#595959,dashed]-> LoginDto
AuthController -[#595959,dashed]-> RegisterDto
AuthController -[#595959,dashed]-> SessionData
AuthController -[#595959,dashed]-> Verify2faDto
AuthGuard -[#595959,dashed]-> JwtService
AuthGuard -[#595959,dashed]-> SessionData
AuthService -[#595959,dashed]-> AuthService
AuthService -[#595959,dashed]-> HashingService
AuthService -[#595959,dashed]-> JwtService
AuthService -[#595959,dashed]-> LoginDto
AuthService -[#595959,dashed]-> RegisterDto
AuthService -[#595959,dashed]-> SessionsService
AuthService -[#595959,dashed]-> UsersService
BootstrapService -[#595959,dashed]-> BootstrapService
BootstrapService -[#595959,dashed]-> RbacService
BootstrapService -[#595959,dashed]-> UsersService
CategoriesController -[#595959,dashed]-> AuthGuard
CategoriesController -[#595959,dashed]-> CategoriesService
CategoriesController -[#595959,dashed]-> CreateCategoryDto
CategoriesController -[#595959,dashed]-> RolesGuard
CategoriesController -[#595959,dashed]-> UpdateCategoryDto
CategoriesRepository -[#595959,dashed]-> CreateCategoryDto
CategoriesRepository -[#595959,dashed]-> DatabaseService
CategoriesRepository -[#595959,dashed]-> UpdateCategoryDto
CategoriesService -[#595959,dashed]-> CategoriesRepository
CategoriesService -[#595959,dashed]-> CategoriesService
CategoriesService -[#595959,dashed]-> CreateCategoryDto
CategoriesService -[#595959,dashed]-> UpdateCategoryDto
ContentsController -[#595959,dashed]-> AuthGuard
ContentsController -[#595959,dashed]-> AuthenticatedRequest
ContentsController -[#595959,dashed]-> ContentsService
ContentsController -[#595959,dashed]-> CreateContentDto
ContentsController -[#595959,dashed]-> OptionalAuthGuard
ContentsController -[#595959,dashed]-> RolesGuard
ContentsController -[#595959,dashed]-> UploadContentDto
ContentsRepository -[#595959,dashed]-> DatabaseService
ContentsRepository -[#595959,dashed]-> FindAllOptions
ContentsRepository -[#595959,dashed]-> NewContentInDb
ContentsService -[#595959,dashed]-> ContentsRepository
ContentsService -[#595959,dashed]-> ContentsService
ContentsService -[#595959,dashed]-> CreateContentDto
ContentsService -[#595959,dashed]-> IMediaService
ContentsService -[#595959,dashed]-> IStorageService
ContentsService -[#595959,dashed]-> MediaProcessingResult
ContentsService -[#595959,dashed]-> MediaService
ContentsService -[#595959,dashed]-> S3Service
ContentsService -[#595959,dashed]-> UploadContentDto
CryptoService -[#595959,dashed]-> EncryptionService
CryptoService -[#595959,dashed]-> HashingService
CryptoService -[#595959,dashed]-> JwtService
CryptoService -[#595959,dashed]-> PostQuantumService
DatabaseService -[#595959,dashed]-> DatabaseService
EncryptionService -[#595959,dashed]-> EncryptionService
FavoritesController -[#595959,dashed]-> AuthenticatedRequest
FavoritesController -[#595959,dashed]-> FavoritesService
FavoritesRepository -[#595959,dashed]-> DatabaseService
FavoritesService -[#595959,dashed]-> FavoritesRepository
FavoritesService -[#595959,dashed]-> FavoritesService
HealthController -[#595959,dashed]-> DatabaseService
IMediaProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
IMediaService -[#595959,dashed]-> MediaProcessingResult
IMediaService -[#595959,dashed]-> ScanResult
ImageProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
ImageProcessorStrategy -[#595959,dashed]-> ImageProcessorStrategy
ImageProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
JwtService -[#595959,dashed]-> JwtService
MailService -[#008200,dashed]-^ IMailService
MailService -[#595959,dashed]-> MailService
MediaController -[#595959,dashed]-> MediaController
MediaController -[#595959,dashed]-> S3Service
MediaService -[#595959,dashed]-> ClamScanner
MediaService -[#008200,dashed]-^ IMediaService
MediaService -[#595959,dashed]-> ImageProcessorStrategy
MediaService -[#595959,dashed]-> MediaProcessingResult
MediaService -[#595959,dashed]-> MediaService
MediaService -[#595959,dashed]-> ScanResult
MediaService -[#595959,dashed]-> VideoProcessorStrategy
OptionalAuthGuard -[#595959,dashed]-> JwtService
OptionalAuthGuard -[#595959,dashed]-> SessionData
PurgeService -[#595959,dashed]-> ContentsRepository
PurgeService -[#595959,dashed]-> PurgeService
PurgeService -[#595959,dashed]-> ReportsRepository
PurgeService -[#595959,dashed]-> SessionsRepository
PurgeService -[#595959,dashed]-> UsersRepository
RbacRepository -[#595959,dashed]-> DatabaseService
RbacService -[#595959,dashed]-> RbacRepository
RbacService -[#595959,dashed]-> RbacService
ReportsController -[#595959,dashed]-> AuthGuard
ReportsController -[#595959,dashed]-> AuthenticatedRequest
ReportsController -[#595959,dashed]-> CreateReportDto
ReportsController -[#595959,dashed]-> ReportsService
ReportsController -[#595959,dashed]-> RolesGuard
ReportsController -[#595959,dashed]-> UpdateReportStatusDto
ReportsRepository -[#595959,dashed]-> DatabaseService
ReportsService -[#595959,dashed]-> CreateReportDto
ReportsService -[#595959,dashed]-> ReportsRepository
ReportsService -[#595959,dashed]-> ReportsService
RolesGuard -[#595959,dashed]-> RbacService
S3Service -[#008200,dashed]-^ IStorageService
S3Service -[#595959,dashed]-> S3Service
SessionsRepository -[#595959,dashed]-> DatabaseService
SessionsService -[#595959,dashed]-> HashingService
SessionsService -[#595959,dashed]-> JwtService
SessionsService -[#595959,dashed]-> SessionsRepository
TagsController -[#595959,dashed]-> TagsService
TagsRepository -[#595959,dashed]-> DatabaseService
TagsService -[#595959,dashed]-> TagsRepository
TagsService -[#595959,dashed]-> TagsService
UsersController -[#595959,dashed]-> AuthGuard
UsersController -[#595959,dashed]-> AuthService
UsersController -[#595959,dashed]-> AuthenticatedRequest
UsersController -[#595959,dashed]-> RolesGuard
UsersController -[#595959,dashed]-> UpdateConsentDto
UsersController -[#595959,dashed]-> UpdateUserDto
UsersController -[#595959,dashed]-> UsersService
UsersRepository -[#595959,dashed]-> DatabaseService
UsersService -[#595959,dashed]-> IMediaService
UsersService -[#595959,dashed]-> IStorageService
UsersService -[#595959,dashed]-> MediaService
UsersService -[#595959,dashed]-> RbacService
UsersService -[#595959,dashed]-> S3Service
UsersService -[#595959,dashed]-> UpdateUserDto
UsersService -[#595959,dashed]-> UsersRepository
UsersService -[#595959,dashed]-> UsersService
VideoProcessorStrategy -[#008200,dashed]-^ IMediaProcessorStrategy
VideoProcessorStrategy -[#595959,dashed]-> MediaProcessingResult
VideoProcessorStrategy -[#595959,dashed]-> VideoProcessorStrategy
@enduml

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."content_type" ADD VALUE 'video';

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1768423315172, "when": 1768423315172,
"tag": "0006_friendly_adam_warlock", "tag": "0006_friendly_adam_warlock",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1769605995410,
"tag": "0007_melodic_synch",
"breakpoints": true
} }
] ]
} }

View File

@@ -4,6 +4,8 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
RUN apk add --no-cache ffmpeg
FROM base AS build FROM base AS build
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "1.4.1", "version": "1.7.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -48,6 +48,7 @@ export const envSchema = z.object({
// Media Limits // Media Limits
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512), MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
MAX_GIF_SIZE_KB: z.coerce.number().default(1024), MAX_GIF_SIZE_KB: z.coerce.number().default(1024),
MAX_VIDEO_SIZE_KB: z.coerce.number().default(10240),
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;

View File

@@ -55,22 +55,31 @@ export class ContentsService {
"image/webp", "image/webp",
"image/gif", "image/gif",
"video/webm", "video/webm",
"video/mp4",
"video/quicktime",
]; ];
if (!allowedMimeTypes.includes(file.mimetype)) { if (!allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException( throw new BadRequestException(
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.", "Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, mp4, mov, gif.",
); );
} }
const isGif = file.mimetype === "image/gif"; const isGif = file.mimetype === "image/gif";
const maxSizeKb = isGif const isVideo = file.mimetype.startsWith("video/");
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024) let maxSizeKb: number;
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
if (isGif) {
maxSizeKb = this.configService.get<number>("MAX_GIF_SIZE_KB", 1024);
} else if (isVideo) {
maxSizeKb = this.configService.get<number>("MAX_VIDEO_SIZE_KB", 10240);
} else {
maxSizeKb = this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
}
if (file.size > maxSizeKb * 1024) { if (file.size > maxSizeKb * 1024) {
throw new BadRequestException( throw new BadRequestException(
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`, `Fichier trop volumineux. Limite pour ${isGif ? "GIF" : isVideo ? "vidéo" : "image"}: ${maxSizeKb} Ko.`,
); );
} }
@@ -87,11 +96,14 @@ export class ContentsService {
// 2. Transcodage // 2. Transcodage
let processed: MediaProcessingResult; let processed: MediaProcessingResult;
if (file.mimetype.startsWith("image/")) { if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
// Image ou GIF -> WebP (format moderne, bien supporté) // Image -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp"); processed = await this.mediaService.processImage(file.buffer, "webp");
} else if (file.mimetype.startsWith("video/")) { } else if (
// Vidéo -> WebM file.mimetype.startsWith("video/") ||
file.mimetype === "image/gif"
) {
// Vidéo ou GIF -> WebM
processed = await this.mediaService.processVideo(file.buffer, "webm"); processed = await this.mediaService.processVideo(file.buffer, "webm");
} else { } else {
throw new BadRequestException("Format de fichier non supporté"); throw new BadRequestException("Format de fichier non supporté");

View File

@@ -12,11 +12,12 @@ import {
export enum ContentType { export enum ContentType {
MEME = "meme", MEME = "meme",
GIF = "gif", GIF = "gif",
VIDEO = "video",
} }
export class CreateContentDto { export class CreateContentDto {
@IsEnum(ContentType) @IsEnum(ContentType)
type!: "meme" | "gif"; type!: "meme" | "gif" | "video";
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
export class UploadContentDto { export class UploadContentDto {
@IsEnum(ContentType) @IsEnum(ContentType)
type!: "meme" | "gif"; type!: "meme" | "gif" | "video";
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@@ -12,7 +12,7 @@ import { categories } from "./categories";
import { tags } from "./tags"; import { tags } from "./tags";
import { users } from "./users"; import { users } from "./users";
export const contentType = pgEnum("content_type", ["meme", "gif"]); export const contentType = pgEnum("content_type", ["meme", "gif", "video"]);
export const contents = pgTable( export const contents = pgTable(
"contents", "contents",

View File

@@ -75,7 +75,7 @@ describe("MediaService", () => {
toFormat: jest.fn().mockReturnThis(), toFormat: jest.fn().mockReturnThis(),
videoCodec: jest.fn().mockReturnThis(), videoCodec: jest.fn().mockReturnThis(),
audioCodec: jest.fn().mockReturnThis(), audioCodec: jest.fn().mockReturnThis(),
outputOptions: jest.fn().mockReturnThis(), addOutputOptions: jest.fn().mockReturnThis(),
on: jest.fn().mockImplementation(function (event, cb) { on: jest.fn().mockImplementation(function (event, cb) {
if (event === "end") setTimeout(cb, 0); if (event === "end") setTimeout(cb, 0);
return this; return this;

View File

@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
private readonly logger = new Logger(VideoProcessorStrategy.name); private readonly logger = new Logger(VideoProcessorStrategy.name);
canHandle(mimeType: string): boolean { canHandle(mimeType: string): boolean {
return mimeType.startsWith("video/"); return mimeType.startsWith("video/") || mimeType === "image/gif";
} }
async process( async process(
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
.toFormat("webm") .toFormat("webm")
.videoCodec("libvpx-vp9") .videoCodec("libvpx-vp9")
.audioCodec("libopus") .audioCodec("libopus")
.outputOptions("-crf 30", "-b:v 0"); .addOutputOptions("-crf", "30", "-b:v", "0");
} else { } else {
command = command command = command
.toFormat("mp4") .toFormat("mp4")
.videoCodec("libaom-av1") .videoCodec("libaom-av1")
.audioCodec("libopus") .audioCodec("libopus")
.outputOptions("-crf 34", "-b:v 0", "-strict experimental"); .addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
} }
command command

View File

@@ -1,141 +1,3 @@
# Sommaire
1. [Introduction au projet](#1-introduction-au-projet)
- [Objectifs principaux](#objectifs-principaux-)
2. [Liste des compétences couvertes par le projet](#2-liste-des-compétences-couvertes-par-le-projet)
3. [Cahier des charges](#3-cahier-des-charges)
- 3.1 [Spécifications fonctionnelles](#31-spécifications-fonctionnelles)
- [Gestion des utilisateurs et authentification (MFA, Sessions)](#gestion-des-utilisateurs-et-authentification-mfa-sessions)
- [Gestion et partage de contenus (Memes & GIFs)](#gestion-et-partage-de-contenus-memes--gifs)
- [Sécurisation avancée (Cryptographie PGP & Post-Quantique)](#sécurisation-avancée-cryptographie-pgp--post-quantique)
- [Panneau dAdministration et Modération](#panneau-dadministration-et-modération)
- [Système de recherche par catégories et tags](#système-de-recherche-par-catégories-et-tags)
- 3.2 [Spécifications non fonctionnelles](#32-spécifications-non-fonctionnelles)
- [Performance & Réactivité (Redis, Caching)](#performance--réactivité-redis-caching)
- [Observabilité et Sécurité du Transport (Sentry, Helmet, Throttler)](#observabilité-et-sécurité-du-transport-sentry-helmet-throttler)
- [Scalabilité (Stockage S3/Minio)](#scalabilité-stockage-s3minio)
- [Expérience utilisateur (UX)](#expérience-utilisateur-ux)
- [SEO (Search Engine Optimization)](#seo-search-engine-optimization)
- [Accessibilité (A11Y)](#accessibilité-a11y)
- [Maintenance et Extensibilité](#maintenance-et-extensibilité)
- [Tests automatisés](#tests-automatisés)
- 3.3 [Charte graphique](#33-charte-graphique)
- [Couleurs](#couleurs)
- [Police décriture](#police-décriture)
- [Logotype et image de marque](#logotype-et-image-de-marque)
- 3.4 [Spécifications de linfrastructure (Docker, PostgreSQL, Redis, Minio)](#34-spécifications-de-linfrastructure-docker-postgresql-redis-minio)
4. [Réalisations](#4-réalisations)
- 4.1 [Organisation des tâches](#41-organisation-des-tâches)
- [Gestion de projet et suivi des tâches](#gestion-de-projet-et-suivi-des-tâches)
- [Gestion des versions (Versioning)](#gestion-des-versions-versioning)
- [Environnement de développement et Monorepo](#environnement-de-développement-et-monorepo)
- [Pipeline CI/CD (Gitea Actions)](#pipeline-cicd-gitea-actions)
- 4.2 [Backend](#42-backend)
- [Architecture du backend (NestJS)](#architecture-du-backend-nestjs)
- [Middleware](#middleware)
- [Guard](#guard)
- [Data Transfer Object (DTO)](#data-transfer-object-dto)
- [B.1 - Installation et configuration de lenvironnement](#b1---installation-et-configuration-de-lenvironnement)
- [B.2 - Modélisation & Base de données (Drizzle ORM, PostgreSQL)](#b2---modélisation--base-de-données-drizzle-orm-postgresql)
- [B.3 - Composant daccès aux données (Drizzle ORM)](#b3---composant-daccès-aux-données-drizzle-orm)
- [B.4 - Composants métier](#b4---composants-métier)
- [B.5 - Flux métier et CRUD](#b5---flux-métier-et-crud)
- [B.6 - Qualité et Tests](#b6---qualité-et-tests)
- [Sécurité & Cryptographie](#sécurité--cryptographie)
- [Veille technologique et de sécurité](#veille-technologique-et-de-sécurité)
- 4.3 [Maquettage](#43-maquettage)
- [Choix de l'outil : Pourquoi PenPot ?](#choix-de-loutil--pourquoi-penpot-)
- [Workflow de Design](#workflow-de-design)
- 4.4 [Frontend](#44-frontend)
- [F.1 - Stack technique (Next.js 16, React 19, Tailwind CSS 4)](#f1---stack-technique-nextjs-16-react-19-tailwind-css-4)
- [F.2 - Architecture et Interfaces](#f2---architecture-et-interfaces)
- [F.3 - Interface dynamique et UX](#f3---interface-dynamique-et-ux)
- [F.4 - SEO et Métadonnées avec Next.js](#f4---seo-et-métadonnées-avec-nextjs)
- [F.5 - Accessibilité et Design Inclusif (A11Y)](#f5---accessibilité-et-design-inclusif-a11y)
- 4.5 [Déploiement et Infrastructure](#45-déploiement-et-infrastructure)
- 4.6 [Écoconception (Green IT) et Accessibilité](#46-écoconception-green-it-et-accessibilité)
5. [Respect de la réglementation (RGPD)](#5-respect-de-la-réglementation-rgpd)
- [Registre des traitements](#registre-des-traitements)
- [Droits des personnes](#droits-des-personnes)
- [Sécurité par défaut (Privacy by Design)](#sécurité-par-défaut-privacy-by-design)
6. [Conclusion](#6-conclusion)
- [Remerciements](#remerciements)
7. [Annexes](#7-annexes)
- [Annexe 1 - Schéma de classe POO du backend](#annexe-1---schéma-de-classe-poo-du-backend)
- [Annexe 2 - Sources et ressources](#annexe-2---sources-et-ressources)
- [Annexe 3 - Glossaire technique](#annexe-3---glossaire-technique)
- [Annexe 4 - Licences et bibliothèques](#annexe-4---licences-et-bibliothèques)
- [Annexe 5 - Dossier technique (Backend)](#annexe-5---dossier-technique-backend)
- [Annexe 6 - Dossier technique (Frontend)](#annexe-6---dossier-technique-frontend)
- [Annexe 7 - Démonstration et accès](#annexe-7---démonstration-et-accès)
# 1. Introduction au projet # 1. Introduction au projet
Memegoat est une plateforme numérique innovante dédiée à la création, au partage et à la découverte de contenus multimédias éphémères et viraux, tels que les mèmes et les GIFs. Développé dans le cadre du titre professionnel **Concepteur Développeur d'Applications (CDA)**, ce projet transcende la simple fonctionnalité de partage social pour devenir une démonstration technique d'architecture logicielle moderne, de sécurité proactive et de conformité réglementaire. Memegoat est une plateforme numérique innovante dédiée à la création, au partage et à la découverte de contenus multimédias éphémères et viraux, tels que les mèmes et les GIFs. Développé dans le cadre du titre professionnel **Concepteur Développeur d'Applications (CDA)**, ce projet transcende la simple fonctionnalité de partage social pour devenir une démonstration technique d'architecture logicielle moderne, de sécurité proactive et de conformité réglementaire.
@@ -152,20 +14,20 @@ Dans un paysage numérique où la protection des données personnelles et la sé
Ce projet a été conçu pour couvrir l'intégralité du REAC (Référentiel d'Emploi, d'Activités et de Compétences) **Concepteur Développeur d'Applications (V04)**. Le tableau suivant détaille comment chaque compétence est mise en œuvre au sein de Memegoat. Ce projet a été conçu pour couvrir l'intégralité du REAC (Référentiel d'Emploi, d'Activités et de Compétences) **Concepteur Développeur d'Applications (V04)**. Le tableau suivant détaille comment chaque compétence est mise en œuvre au sein de Memegoat.
| Compétence (CP) | Description | Mise en œuvre dans Memegoat | | Compétence (CP) | Description | Mise en œuvre dans Memegoat |
|:----------------|:---------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| |:----------------|:---------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **CP 1** | Maquetter une application | Conception de maquettes haute fidélité sous **Penpot**, respectant une approche mobile-first et les principes d'accessibilité. | | **CP 1** | Maquetter une application | Conception de maquettes haute fidélité sous **Penpot**, respectant une approche mobile-first et les principes d'accessibilité. Voir [4.3 Maquettage](#43-maquettage). |
| **CP 2** | Réaliser une interface utilisateur web statique et adaptable | Intégration **Next.js 16** avec **Tailwind CSS 4** pour un rendu réactif et optimisé. | | **CP 2** | Réaliser une interface utilisateur web statique et adaptable | Intégration **Next.js 16** avec **Tailwind CSS 4** pour un rendu réactif et optimisé. Voir [F.1 Stack technique](#f1---stack-technique-nextjs-16-react-19-tailwind-css-4). |
| **CP 3** | Développer une interface utilisateur web dynamique | Développement de composants **React 19** utilisant les Server Actions et une gestion d'état optimisée. | | **CP 3** | Développer une interface utilisateur web dynamique | Développement de composants **React 19** utilisant les Server Actions et une gestion d'état optimisée. Voir [F.3 Interface dynamique](#f3---interface-dynamique-et-ux). |
| **CP 4** | Réaliser une interface utilisateur avec une solution de gestion de contenu ou e-commerce | Création d'un module de gestion de contenu personnalisé pour l'administration et la modération. | | **CP 4** | Réaliser une interface utilisateur avec une solution de gestion de contenu ou e-commerce | Création d'un module de gestion de contenu personnalisé pour l'administration et la modération. Voir [4.4 Analyse](#44-analyse-et-conception). |
| **CP 5** | Créer une base de données | Modélisation et implémentation sous **PostgreSQL** via **Drizzle ORM**, incluant le chiffrement natif PGP. | | **CP 5** | Créer une base de données | Modélisation et implémentation sous **PostgreSQL** via **Drizzle ORM**, incluant le chiffrement natif PGP. Voir [B.2 Modélisation](#b2---modélisation--base-de-données-drizzle-orm-postgresql). |
| **CP 6** | Développer les composants daccès aux données | Implémentation de services de données sous NestJS avec un typage strict (TypeScript) et validation via Zod. | | **CP 6** | Développer les composants daccès aux données | Implémentation de services de données sous NestJS avec un typage strict (TypeScript) et validation via Zod. Voir [B.3 Accès aux données](#b3---composant-daccès-aux-données-drizzle-orm). |
| **CP 7** | Développer la partie back-end dune application web ou mobile | Architecture modulaire **NestJS** intégrant JWT, RBAC et services métier complexes. | | **CP 7** | Développer la partie back-end dune application web ou mobile | Architecture modulaire **NestJS** intégrant JWT, RBAC et services métier complexes. Voir [4.2 Backend](#42-backend). |
| **CP 8** | Élaborer et mettre en œuvre des composants dans une application de gestion de contenu ou e-commerce | Développement de tableaux de bord administratifs pour le suivi des signalements et la gestion utilisateur. | | **CP 8** | Élaborer et mettre en œuvre des composants dans une application de gestion de contenu ou e-commerce | Développement de tableaux de bord administratifs pour le suivi des signalements et la gestion utilisateur. Voir [B.5 Flux métier](#b5---flux-métier-et-crud). |
| **CP 9** | Concevoir une application | Élaboration de diagrammes UML et choix d'une architecture monorepo pour la cohérence globale. | | **CP 9** | Concevoir une application | Élaboration de diagrammes UML et choix d'une architecture monorepo pour la cohérence globale. Voir [4.4 Analyse](#44-analyse-et-conception). |
| **CP 10** | Collaborer à la gestion dun projet informatique et à lorganisation de lenvironnement de développement | Utilisation de Git (GitFlow), **Docker Compose** et gestion des tâches en méthode Agile. | | **CP 10** | Collaborer à la gestion dun projet informatique et à lorganisation de lenvironnement de développement | Utilisation de Git (GitFlow), **Docker Compose** et gestion des tâches en méthode Agile. Voir [4.1 Organisation](#41-organisation-des-tâches). |
| **CP 11** | Préparer le déploiement de lapplication | Configuration de conteneurs Docker pour l'orchestration des services (API, DB, Redis, MinIO). | | **CP 11** | Préparer le déploiement de lapplication | Configuration de conteneurs Docker pour l'orchestration des services (API, DB, Redis, MinIO). Voir [4.5 Déploiement](#45-déploiement-et-infrastructure). |
| **CP 12** | Organiser la veille technologique | Veille continue sur les évolutions de React 19, la sécurité Post-Quantique (ML-KEM) et le Green IT. | | **CP 12** | Organiser la veille technologique | Veille continue sur les évolutions de React 19, la sécurité Post-Quantique (ML-KEM) et le Green IT. Voir [B.6 Qualité et Tests](#b6---qualité-et-tests). |
# 3. Cahier des charges # 3. Cahier des charges
@@ -217,6 +79,9 @@ L'utilisation de Helmet permet d'injecter automatiquement les protections suivan
- **Strict-Transport-Security (HSTS)** : Force le navigateur à utiliser uniquement des connexions HTTPS sécurisées. - **Strict-Transport-Security (HSTS)** : Force le navigateur à utiliser uniquement des connexions HTTPS sécurisées.
- **X-Content-Type-Options** : Empêche le navigateur d'interpréter un fichier autrement que par son type MIME déclaré, neutralisant certaines attaques par upload de fichiers. - **X-Content-Type-Options** : Empêche le navigateur d'interpréter un fichier autrement que par son type MIME déclaré, neutralisant certaines attaques par upload de fichiers.
#### Détection de Crawlers et Protection contre le Scraping
Un middleware dédié (`CrawlerDetectionMiddleware`) analyse les motifs de requêtes et les User-Agents. Il identifie les comportements suspects (tentatives d'accès à des fichiers sensibles comme `.env`, scans de vulnérabilités PHP) et les robots connus pour optimiser la charge serveur et protéger le contenu des mèmes contre le pillage automatique.
### Scalabilité (Stockage S3/Minio) ### Scalabilité (Stockage S3/Minio)
L'architecture sépare les serveurs d'application du stockage des actifs numériques via le protocole **S3** (MinIO). Cette approche facilite la scalabilité horizontale et permet de servir les médias via un réseau de diffusion de contenu (CDN). L'architecture sépare les serveurs d'application du stockage des actifs numériques via le protocole **S3** (MinIO). Cette approche facilite la scalabilité horizontale et permet de servir les médias via un réseau de diffusion de contenu (CDN).
@@ -255,13 +120,22 @@ Le choix s'est porté sur **Ubuntu Sans** pour sa lisibilité exceptionnelle et
### Logotype et image de marque ### Logotype et image de marque
Le logotype représente une chèvre stylisée, symbole du **"G.O.A.T"**, incarnant l'ambition de devenir la référence ultime des mèmes tout en inspirant confiance par sa rigueur technique. Le logotype représente une chèvre stylisée, symbole du **"G.O.A.T"**, incarnant l'ambition de devenir la référence ultime des mèmes tout en inspirant confiance par sa rigueur technique.
## 3.4 Spécifications de linfrastructure (Docker, PostgreSQL, Redis, Minio) ## 3.4 Spécifications de linfrastructure
L'infrastructure est entièrement conteneurisée avec **Docker**, garantissant la parité entre environnements. L'infrastructure est entièrement conteneurisée avec **Docker**, garantissant la parité entre environnements.
- **Caddy** : Reverse proxy avec gestion automatique du SSL (TLS 1.3). - **Caddy** : Reverse proxy avec gestion automatique du SSL (TLS 1.3). Il agit comme point d'entrée unique, gérant le routage vers le frontend et le backend tout en assurant une couche de sécurité supplémentaire.
- **PostgreSQL** : Stockage relationnel avec extension `pgcrypto` pour PGP. - **PostgreSQL 17** : Stockage relationnel avec extension `pgcrypto` pour le chiffrement PGP.
- **Redis** : Cache de performance et gestion des sessions. - **Redis 7** : Utilisé pour la mise en cache des requêtes API et la gestion des sessions à haute performance.
- **MinIO** : Stockage d'objets compatible S3 pour les médias. - **MinIO** : Serveur de stockage d'objets auto-hébergé, compatible avec l'API Amazon S3, utilisé pour la persistance des fichiers médias.
- **ClamAV** : Service d'analyse antivirus intégré au flux d'upload pour protéger l'infrastructure contre les fichiers malveillants.
## 3.5 Sécurité et Conformité
Le projet a été conçu selon le principe de **Défense en Profondeur**.
- **Sécurité Applicative** : Validation rigoureuse via Zod, hachage Argon2id, et protection contre les failles OWASP (XSS, CSRF) via Helmet.
- **Sécurité des Données** : Chiffrement PGP au repos et cryptographie post-quantique (ML-KEM) pour les échanges de clés.
- **Disponibilité** : Architecture conteneurisée permettant un redémarrage rapide et une isolation des services.
- **Conformité RGPD** : Gestion native des droits utilisateurs (accès, oubli) et minimisation des données collectées.
# 4. Réalisations # 4. Réalisations
@@ -293,7 +167,40 @@ L'automatisation est au cœur du processus de qualité. Un pipeline **CI/CD** a
2. **Build** : Les images Docker sont construites pour valider la compilation. 2. **Build** : Les images Docker sont construites pour valider la compilation.
3. **Déploiement** : L'application est automatiquement déployée sur le serveur de production via Docker Compose, assurant une livraison continue et fiable. 3. **Déploiement** : L'application est automatiquement déployée sur le serveur de production via Docker Compose, assurant une livraison continue et fiable.
## 4.2 Backend ## 4.2 Analyse et Conception
La phase de conception est le socle sur lequel repose la robustesse de Memegoat. Elle a permis d'anticiper les défis techniques liés à la sécurité et à la gestion des médias.
### Analyse des besoins et Personas
L'analyse a identifié trois profils types (Personas) :
1. **Le Créateur de contenu** : Recherche la simplicité d'upload et une visibilité maximale.
2. **Le Consommateur** : Privilégie la fluidité de navigation et la pertinence du flux (tendances).
3. **Le Modérateur** : Nécessite des outils d'administration efficaces pour garantir la sécurité de la communauté.
### User Stories
- "En tant qu'utilisateur, je veux pouvoir téléverser un mème de manière sécurisée afin de le partager."
- "En tant que modérateur, je veux pouvoir suspendre un contenu signalé pour non-respect des règles."
- "En tant qu'utilisateur soucieux de ma vie privée, je veux pouvoir activer la double authentification (MFA)."
### Diagramme de Cas d'Utilisation (Use Case)
Il illustre les interactions majeures : Inscription, Recherche, Upload, Modération, et Gestion de profil.
### Diagramme de Séquence (Flux d'Upload)
Détaille le passage du média à travers le scanner antivirus ClamAV avant son stockage sur MinIO et son référencement en base de données.
## 4.3 Maquettage
Le design de Memegoat a été guidé par une approche **Mobile-First** et une esthétique épurée.
### Choix de l'outil : Pourquoi PenPot ?
Le choix de **PenPot** s'inscrit dans la démarche Open-Source du projet. Contrairement à Figma, PenPot permet une pleine maîtrise des assets (format SVG natif) et facilite la collaboration sans contraintes de licences propriétaires, tout en offrant des fonctionnalités de prototypage avancées.
### Workflow de Design
1. **Wireframes** : Définition de la structure sans distraction visuelle.
2. **Maquettes Haute Fidélité** : Application de la charte graphique (Ubuntu Sans, palette de gris profond).
3. **Prototypage** : Simulation des transitions pour valider l'UX (User Experience) avant le développement.
## 4.4 Backend
L'architecture backend de Memegoat a été conçue pour être à la fois robuste, évolutive et sécurisée. Le choix s'est porté sur **NestJS**, un framework Node.js progressif, pour sa capacité à structurer le code de manière modulaire et son support natif de **TypeScript**. L'architecture backend de Memegoat a été conçue pour être à la fois robuste, évolutive et sécurisée. Le choix s'est porté sur **NestJS**, un framework Node.js progressif, pour sa capacité à structurer le code de manière modulaire et son support natif de **TypeScript**.
@@ -624,6 +531,12 @@ Chaque fichier téléversé subit un flux de vérification rigoureux avant trait
- **Scan ClamAV** : Utilisation d'un démon ClamAV pour analyser le binaire de chaque image ou GIF à la recherche de malwares ou de scripts malveillants encapsulés. - **Scan ClamAV** : Utilisation d'un démon ClamAV pour analyser le binaire de chaque image ou GIF à la recherche de malwares ou de scripts malveillants encapsulés.
- **Validation Zod** : Toutes les entrées de l'API sont validées par des schémas Zod, empêchant les injections de données malformées ou les attaques par pollution de prototypes. - **Validation Zod** : Toutes les entrées de l'API sont validées par des schémas Zod, empêchant les injections de données malformées ou les attaques par pollution de prototypes.
#### Amorçage Sécurisé (Bootstrap Service)
Le système intègre un mécanisme d'amorçage unique (`BootstrapService`) qui génère un jeton à usage unique au premier démarrage si aucun administrateur n'est détecté. Cela permet de créer le premier compte "Admin" de manière sécurisée sans exposer d'identifiants par défaut dans le code ou la base de données.
#### Purge et Maintenance Automatisée (RGPD)
Un service de purge automatique (`PurgeService`) s'exécute quotidiennement pour garantir que les données supprimées (Soft Delete) ou expirées (Sessions, Signalements) sont physiquement retirées du système après 30 jours, assurant une conformité stricte avec le principe de limitation de la conservation du RGPD.
### Veille technologique et de sécurité ### Veille technologique et de sécurité
#### OWASP Top Ten : Priorité à la sécurité applicative #### OWASP Top Ten : Priorité à la sécurité applicative
@@ -645,67 +558,102 @@ Utilisation de **PenPot** comme alternative Open-Source à Figma, favorisant la
2. **Maquettes Haute Fidélité** : Application de l'identité visuelle. 2. **Maquettes Haute Fidélité** : Application de l'identité visuelle.
3. **Prototypage** : Simulation du parcours utilisateur complet. 3. **Prototypage** : Simulation du parcours utilisateur complet.
## 4.4 Frontend ## 4.4 Analyse et Conception
### Analyse des besoins et Personas
La phase d'analyse a permis d'identifier les besoins des utilisateurs cibles :
- **Le Consommateur** : Recherche un divertissement rapide, fluide et accessible sur mobile.
- **Le Créateur** : Souhaite partager ses contenus facilement tout en ayant l'assurance que ses données sont protégées.
- **Le Modérateur/Admin** : Nécessite des outils robustes pour maintenir un environnement sain.
### User Stories
Les fonctionnalités ont été priorisées via la méthode **MoSCoW** :
- **Must (Indispensable)** : Inscription sécurisée (MFA), Upload de mèmes, Consultation des tendances.
- **Should (Important)** : Mise en favoris, Recherche par tags, Signalement de contenu.
- **Could (Optionnel)** : Profils personnalisés avancés, Statistiques de vues.
### Diagramme de Cas d'Utilisation (Use Case)
Le diagramme suivant illustre les interactions des acteurs avec le système :
```mermaid
graph LR
V[Visiteur]
U[Utilisateur Authentifié]
A[Administrateur]
V --- C1(Consulter les tendances)
V --- C2(S'inscrire / Se connecter)
U --- C3(Poster un mème)
U --- C4(Ajouter aux favoris)
U --- C5(Signaler un contenu)
A --- C6(Modérer les contenus)
A --- C7(Gérer les utilisateurs)
A --- C8(Consulter les statistiques)
```
### Diagramme de Séquence (Flux d'Upload)
Détail des interactions lors de la publication d'un contenu, intégrant la sécurité et l'optimisation :
```mermaid
sequenceDiagram
participant User as Utilisateur
participant API as Backend (NestJS)
participant AV as Scanner (ClamAV)
participant P as Processeur (Sharp/FFmpeg)
participant S3 as Stockage (MinIO)
participant DB as PostgreSQL
User->>API: POST /contents/upload (Multipart)
API->>AV: Scan Antivirus du buffer
AV-->>API: Résultat: Sain
par Optimisation et Stockage
API->>P: Conversion WebP / Transcodage
P-->>API: Média optimisé
API->>S3: Transfert vers le bucket
end
API->>DB: INSERT INTO contents (Metadata + S3 Key)
DB-->>API: Confirmation (ID)
API-->>User: 201 Created (Affichage)
```
## 4.5 Frontend
L'interface utilisateur de Memegoat a été développée avec **Next.js**, en tirant parti des dernières avancées de l'écosystème React pour offrir une expérience fluide, performante et accessible.
### F.1 - Stack technique (Next.js 16, React 19, Tailwind CSS 4) ### F.1 - Stack technique (Next.js 16, React 19, Tailwind CSS 4)
L'interface de Memegoat repose sur une stack à la pointe de l'écosystème web, choisie pour ses performances et sa maintenabilité : L'interface de Memegoat repose sur une stack à la pointe de l'écosystème web, choisie pour ses performances et sa maintenabilité :
- **Next.js 16 (App Router)** : Utilisation du framework de référence pour React, permettant un rendu hybride. Les pages sont pré-rendues côté serveur (SSR) pour le SEO, tandis que les interactions dynamiques sont gérées côté client. - **Next.js 16 (App Router)** : Utilisation du framework de référence pour React, permettant un rendu hybride. Les pages sont pré-rendues côté serveur (SSR) pour le SEO, tandis que les interactions dynamiques sont gérées côté client.
- **React 19** : Cette version majeure introduit des améliorations significatives, notamment dans la gestion des formulaires avec les **Server Actions** et le support natif de l'asynchronisme, réduisant drastiquement le code "boilerplate" de gestion d'état. - **React 19** : Cette version majeure introduit des améliorations significatives, notamment dans la gestion des formulaires avec les **Server Actions** et le support natif de l'asynchronisme (use, transition API), réduisant drastiquement le code "boilerplate" de gestion d'état.
- **Tailwind CSS 4** : La nouvelle itération de ce framework "Utility-First" offre une compilation plus rapide et une syntaxe simplifiée, permettant de construire des interfaces complexes sans quitter le fichier HTML/JSX. - **Tailwind CSS 4** : La nouvelle itération de ce framework "Utility-First" offre une compilation ultra-rapide et une configuration simplifiée via CSS-native variables, permettant de construire des interfaces complexes sans quitter le fichier HTML/JSX.
### F.2 - Architecture et Interfaces ### F.2 - Architecture et Interfaces
L'architecture frontend suit les principes de la **composabilité** et de la séparation des responsabilités. Le frontend est organisé en composants réutilisables, suivant les principes de l'**Atomic Design**.
L'architecture frontend suit les principes de la **composabilité** et de la séparation des responsabilités. - **Composants et Design System** : Le projet utilise **Shadcn UI**, basé sur **Radix UI**, pour fournir une bibliothèque de composants non stylés mais hautement accessibles.
- **Type-Safety** : Les interfaces TypeScript sont partagées avec le backend, garantissant que les données affichées correspondent exactement aux données envoyées par l'API.
#### Composants et Design System - **Rendu Hybride** : Nous tirons pleinement parti des **React Server Components (RSC)**. Contrairement aux approches traditionnelles où tout le JavaScript est envoyé au client, les RSC permettent d'exécuter la logique lourde directement sur le serveur.
Le projet utilise **Shadcn UI**, basé sur **Radix UI**, pour fournir une bibliothèque de composants non stylés mais hautement accessibles. Cela garantit que chaque bouton, menu ou fenêtre modale respecte les standards WAI-ARIA sans effort supplémentaire. Le design system est centralisé dans la configuration Tailwind, assurant une cohérence visuelle parfaite sur l'ensemble du site.
#### Rendu Hybride et Performance
Nous tirons pleinement parti des **React Server Components (RSC)**. Contrairement aux approches traditionnelles où tout le JavaScript est envoyé au client, les RSC permettent d'exécuter la logique lourde et les requêtes à la base de données directement sur le serveur. Le client ne reçoit que le HTML final et le JavaScript strictement nécessaire à l'interactivité, améliorant considérablement le **Time to Interactive (TTI)**.
### F.3 - Interface dynamique et UX ### F.3 - Interface dynamique et UX
L'expérience utilisateur est au cœur du développement :
#### Flux de données et Server Actions - **Flux de données et Server Actions** : Pour les mutations de données (comme le partage d'un mème ou l'ajout aux favoris), Memegoat utilise les **Server Actions**, simplifiant l'architecture en éliminant le besoin de définir manuellement des API routes dédiées.
Pour les mutations de données (comme le partage d'un mème ou l'ajout aux favoris), Memegoat utilise les **Server Actions**. Cette technologie permet d'appeler des fonctions serveur directement depuis des composants client, avec une gestion intégrée des états de chargement et des erreurs. Cela simplifie l'architecture en éliminant le besoin de définir manuellement des API routes dédiées pour chaque petite interaction. - **Optimistic Updates** : Pour des actions comme la mise en favoris, l'interface réagit instantanément avant même la confirmation du serveur, renforçant la sensation de fluidité.
- **Streaming et Suspense** : L'utilisation de placeholders animés (**Skeletons**) pendant le chargement des contenus réduit la perception du temps d'attente.
#### Streaming et Suspense
Pour éviter de bloquer l'affichage de la page entière en attendant les données, nous utilisons le **Streaming avec React Suspense**. Les parties critiques de l'interface (comme la barre de navigation) s'affichent instantanément, tandis que les flux de mèmes se chargent progressivement avec des états de squelette (**Skeletons**), offrant une sensation de rapidité et de fluidité à l'utilisateur.
#### Gestion des médias côté client
L'interface intègre une prévisualisation interactive pour les uploads. Avant même l'envoi au serveur, le client valide la taille et le type du fichier, et génère une URL temporaire pour afficher le média, permettant à l'utilisateur de recadrer ou de confirmer son choix instantanément.
### F.4 - SEO et Métadonnées avec Next.js ### F.4 - SEO et Métadonnées avec Next.js
Memegoat est optimisé pour les moteurs de recherche :
Memegoat tire profit de la puissance de la **Metadata API** de Next.js pour assurer un référencement optimal et une présence sociale forte. - **Génération dynamique de métadonnées** : Chaque mème possède son propre titre, description et image OpenGraph générés dynamiquement via la fonction `generateMetadata`.
- **Données structurées (JSON-LD)** : Intégration de schémas (ImageObject, VideoObject) pour aider les moteurs de recherche à indexer le contenu de manière sémantique et favoriser l'apparition dans les "rich snippets".
#### Métadonnées statiques et dynamiques
- **Statiques (layout.tsx)** : Définition des éléments globaux tels que le nom du site, le template de titre (`%s | MemeGoat`), les icônes (favicon, SVG coloré) et les paramètres de base d'OpenGraph.
- **Dynamiques (generateMetadata)** : Pour les pages de contenu (mèmes) et les catégories, nous utilisons la fonction `generateMetadata`. Elle permet de récupérer les informations en base de données (titre du mème, description, slug) pour générer des balises uniques. Cela garantit que chaque mème partagé affiche son propre titre et sa propre image d'aperçu sur les réseaux sociaux.
#### Optimisation OpenGraph et Twitter
L'application configure finement les en-têtes `og:title`, `og:description` et `og:image`. L'utilisation d'images OpenGraph dynamiques permet de booster le taux de clic lors des partages sur des plateformes comme X (Twitter), LinkedIn ou Discord.
#### Données structurées JSON-LD
Pour faciliter le travail des moteurs de recherche, Memegoat injecte des scripts JSON-LD. Ces microdonnées informent les robots que le contenu est de type "ImageObject" ou "VideoObject", précisant l'auteur, la date de publication et les mots-clés associés, favorisant ainsi l'apparition dans les "rich snippets" de Google.
### F.5 - Accessibilité et Design Inclusif (A11Y) ### F.5 - Accessibilité et Design Inclusif (A11Y)
Le projet respecte les standards d'accessibilité :
- **Composants Radix UI / Shadcn** : Utilisation de primitives accessibles respectant les spécifications WAI-ARIA (Gestion du Focus Trap, Navigation Clavier).
- **Contraste et Navigation** : Respect des ratios de contraste WCAG et support complet de la navigation au clavier avec une gestion visible du focus.
- **Sémantique HTML** : Utilisation rigoureuse des balises sémantiques (`<header>`, `<main>`, `<section>`) pour faciliter la navigation des lecteurs d'écran.
L'accessibilité est intégrée dès la phase de maquettage et vérifiée tout au long de l'intégration. ## 4.6 Déploiement et Infrastructure
#### Composants Radix UI et WAI-ARIA
Nous utilisons **Radix UI** pour les composants complexes (fenêtres modales, menus déroulants, accordéons). Ces composants "headless" gèrent toute la logique d'accessibilité :
- Gestion du **Focus Trap** dans les modales.
- Navigation par flèches clavier dans les menus.
- Support natif des attributs ARIA (`aria-expanded`, `aria-controls`, etc.).
#### Sémantique et Hiérarchie
Le code HTML respecte une hiérarchie stricte des titres (`<h1>` à `<h6>`) et utilise des balises sémantiques (`<header>`, `<main>`, `<footer>`, `<section>`). Chaque image dispose d'un attribut `alt` explicite (ou `alt=""` pour les images décoratives), et les boutons ont des labels textuels ou des `aria-label` lorsqu'ils ne contiennent que des icônes.
#### Tests d'accessibilité
Pendant le développement, nous utilisons des outils comme **Lighthouse** et des extensions de type **Axe DevTools** pour identifier et corriger les obstacles à la navigation (contrastes insuffisants, cibles de clic trop petites, erreurs de sémantique).
## 4.5 Déploiement et Infrastructure
L'infrastructure de Memegoat est conçue pour être portable, scalable et sécurisée, s'appuyant sur les standards de l'industrie. L'infrastructure de Memegoat est conçue pour être portable, scalable et sécurisée, s'appuyant sur les standards de l'industrie.
@@ -723,7 +671,7 @@ En façade, nous utilisons **Caddy** comme serveur web et reverse proxy. Contrai
### Orchestration des services ### Orchestration des services
L'isolation réseau est assurée par des réseaux Docker privés. Seul le proxy Caddy est exposé sur les ports 80 et 443. La communication entre le backend et la base de données ou le cache s'effectue sur un réseau interne, réduisant considérablement la surface d'attaque. L'isolation réseau est assurée par des réseaux Docker privés. Seul le proxy Caddy est exposé sur les ports 80 et 443. La communication entre le backend et la base de données ou le cache s'effectue sur un réseau interne, réduisant considérablement la surface d'attaque.
## 4.6 Écoconception (Green IT) et Accessibilité ## 4.7 Écoconception et Accessibilité
Memegoat intègre des principes de sobriété numérique pour réduire son impact environnemental tout en améliorant l'expérience utilisateur. Memegoat intègre des principes de sobriété numérique pour réduire son impact environnemental tout en améliorant l'expérience utilisateur.
@@ -742,13 +690,22 @@ L'inclusion est au cœur du développement. Memegoat suit les recommandations du
# 5. Respect de la réglementation (RGPD) # 5. Respect de la réglementation (RGPD)
### Registre des traitements ### Registre des traitements
Collecte limitée aux données strictement nécessaires (Username, Email chiffré) pour le fonctionnement du service. L'application tient à jour un registre des traitements limitant la collecte aux données strictement nécessaires au fonctionnement du service :
- **Utilisateur** : Pseudonyme, Email (chiffré PGP), Mot de passe (haché Argon2id).
- **Médias** : Mèmes et GIFs téléversés, métadonnées associées.
- **Sécurité** : Logs d'audit (actions sensibles), Sessions (chiffrées).
### Droits des personnes ### Droits des personnes
Mécanismes d'export de données (portabilité) et de suppression définitive (droit à l'oubli). Memegoat intègre nativement des mécanismes pour répondre aux sollicitations des utilisateurs :
- **Droit d'accès et portabilité** : Possibilité d'exporter l'intégralité des données rattachées à un compte via un service dédié (`exportUserData`).
- **Droit à l'effacement (Droit à l'oubli)** : Implémentation du **Soft Delete** permettant une suppression logique immédiate pour l'utilisateur, suivie d'une purge physique automatisée après 30 jours par le `PurgeService`. Ce délai permet de prévenir les suppressions accidentelles et de conserver les preuves nécessaires en cas de litige ou de réquisition judiciaire.
- **Droit d'opposition et de rectification** : Interface de gestion de compte permettant la mise à jour ou la suppression des informations personnelles à tout moment.
- **Information des utilisateurs** : Une politique de confidentialité claire est accessible, détaillant la finalité des traitements et la durée de conservation des données.
### Sécurité par défaut (Privacy by Design) ### Sécurité par défaut (Privacy by Design)
Minimisation des données et chiffrement systématique des informations identifiables. - **Minimisation des données** : Seules les informations essentielles sont conservées.
- **Chiffrement systématique** : Les données identifiables (PII) sont chiffrées dès leur réception et avant stockage en base de données.
- **Transparence** : Information claire de l'utilisateur sur l'usage de ses données lors de l'inscription.
# 6. Conclusion # 6. Conclusion
@@ -761,65 +718,18 @@ Je tiens à remercier l'équipe pédagogique pour son accompagnement tout au lon
### Annexe 1 - Schéma de classe POO du backend ### Annexe 1 - Schéma de classe POO du backend
Le diagramme suivant représente les entités principales du domaine et leurs relations au sein du backend NestJS. Le schéma suivant représente l'architecture logicielle du backend NestJS, mettant en évidence la modularité du système et les relations entre les contrôleurs, services et repositories.
```mermaid ![Diagramme de classes Backend](./backend.plantuml)
classDiagram
class User {
+UUID uuid
+String username
+String email (Encrypted)
+String emailHash
+String passwordHash
+String avatarUrl
+Enum status
+Boolean isTwoFactorEnabled
+DateTime createdAt
+softDelete()
}
class Content { *Note : Le diagramme complet est disponible au format PlantUML dans le fichier `backend.plantuml` à la racine du projet.*
+UUID id
+UUID userId
+Enum type
+String title
+String slug
+String storageKey
+Int views
+Int usageCount
+DateTime createdAt
+incrementViews()
}
class Category {
+UUID id
+String name
+String slug
}
class Tag {
+UUID id
+String name
}
class Report {
+UUID id
+UUID contentId
+String reason
+Enum status
+DateTime createdAt
}
User "1" -- "0..*" Content : owns
Content "0..*" -- "1" Category : categorized_in
Content "0..*" -- "0..*" Tag : tagged_with
Content "1" -- "0..*" Report : has_reports
```
### Annexe 2 - Sources et ressources ### Annexe 2 - Sources et ressources
- [Documentation NestJS](https://docs.nestjs.com/) - [Documentation NestJS](https://docs.nestjs.com/)
- [Documentation Next.js](https://nextjs.org/docs) - [Documentation Next.js](https://nextjs.org/docs)
- [Guide de sécurité OWASP](https://owasp.org/www-project-top-ten/) - [Guide de sécurité OWASP](https://owasp.org/www-project-top-ten/)
- [Standard NIST Post-Quantum (ML-KEM)](https://csrc.nist.gov/pubs/fips/203/final)
- [Référentiel Général d'Accessibilité (RGAA)](https://www.numerique.gouv.fr/publications/rgaa-accessibilite/)
### Annexe 3 - Glossaire technique ### Annexe 3 - Glossaire technique
@@ -827,6 +737,10 @@ classDiagram
* **Définition :** Contraction du mot "Accessibility" (11 lettres entre le A et le Y). * **Définition :** Contraction du mot "Accessibility" (11 lettres entre le A et le Y).
* **Explication :** Désigne l'ensemble des pratiques visant à rendre les services numériques utilisables par tous, y compris les personnes en situation de handicap (visuel, moteur, auditif, etc.). Dans Memegoat, cela se traduit par l'utilisation de composants sémantiques et le respect des normes WCAG. * **Explication :** Désigne l'ensemble des pratiques visant à rendre les services numériques utilisables par tous, y compris les personnes en situation de handicap (visuel, moteur, auditif, etc.). Dans Memegoat, cela se traduit par l'utilisation de composants sémantiques et le respect des normes WCAG.
* **ANSSI (Agence Nationale de la Sécurité des Systèmes d'Information) :**
* **Définition :** Autorité nationale française en matière de cybersécurité.
* **Explication :** Memegoat suit les recommandations de l'ANSSI pour le choix des algorithmes de hachage (Argon2id) et la configuration des protocoles TLS afin de garantir un niveau de sécurité étatique.
* **API (Interface de Programmation d'Application) :** * **API (Interface de Programmation d'Application) :**
* **Définition :** Ensemble de règles et de protocoles permettant à deux logiciels de communiquer entre eux. * **Définition :** Ensemble de règles et de protocoles permettant à deux logiciels de communiquer entre eux.
* **Explication :** Dans ce projet, l'API NestJS sert de pont entre le frontend (Next.js) et les données stockées en base. Elle expose des points d'accès (endpoints) sécurisés pour récupérer ou modifier les mèmes et les profils utilisateurs. * **Explication :** Dans ce projet, l'API NestJS sert de pont entre le frontend (Next.js) et les données stockées en base. Elle expose des points d'accès (endpoints) sécurisés pour récupérer ou modifier les mèmes et les profils utilisateurs.
@@ -839,6 +753,10 @@ classDiagram
* **Définition :** Chaîne d'outils (toolchain) ultra-rapide pour le web. * **Définition :** Chaîne d'outils (toolchain) ultra-rapide pour le web.
* **Explication :** Il remplace ESLint et Prettier pour assurer le formatage et le linting du code. Son utilisation garantit une base de code propre, homogène et performante, tout en accélérant le workflow de développement. * **Explication :** Il remplace ESLint et Prettier pour assurer le formatage et le linting du code. Son utilisation garantit une base de code propre, homogène et performante, tout en accélérant le workflow de développement.
* **Blind Indexing (Indexation Aveugle) :**
* **Définition :** Technique permettant de rechercher des données chiffrées sans les déchiffrer.
* **Explication :** Utilisé pour l'unicité des emails. On stocke un hash de l'email à côté de l'email chiffré PGP. Cela permet de vérifier si un email existe déjà en base sans avoir à déchiffrer tous les emails de la table.
* **CSP (Content Security Policy) :** * **CSP (Content Security Policy) :**
* **Définition :** Couche de sécurité supplémentaire qui aide à détecter et atténuer certains types d'attaques, comme le XSS. * **Définition :** Couche de sécurité supplémentaire qui aide à détecter et atténuer certains types d'attaques, comme le XSS.
* **Explication :** En définissant quelles sources de contenu (scripts, images) sont autorisées, Memegoat empêche l'exécution de code malveillant injecté par un tiers. * **Explication :** En définissant quelles sources de contenu (scripts, images) sont autorisées, Memegoat empêche l'exécution de code malveillant injecté par un tiers.
@@ -930,13 +848,4 @@ Le projet Memegoat repose exclusivement sur des technologies Open-Source respect
- **Sharp** : Licence Apache 2.0. - **Sharp** : Licence Apache 2.0.
- **FFmpeg** : Licence LGPL / GPL (utilisé via wrapper fluent-ffmpeg). - **FFmpeg** : Licence LGPL / GPL (utilisé via wrapper fluent-ffmpeg).
- **ClamAV** : Licence GPL. - **ClamAV** : Licence GPL.
- **MinIO** : Licence GNU AGPL v3. - **MinIO** : Licence GNU AGPL v3.
### Annexe 5 - Dossier technique (Backend)
*Documentation détaillée des API et de la logique serveur.*
### Annexe 6 - Dossier technique (Frontend)
*Documentation détaillée des composants et de la gestion d'état client.*
### Annexe 7 - Démonstration et accès
*Liens vers l'instance de démonstration et codes d'accès de test.*

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.4.1", "version": "1.7.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -50,7 +50,7 @@ export default async function MemePage({
Retour au flux Retour au flux
</Link> </Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<ContentCard content={content} /> <ContentCard content={content} />
</div> </div>

View File

@@ -42,7 +42,7 @@ import type { Category } from "@/types/content";
const uploadSchema = z.object({ const uploadSchema = z.object({
title: z.string().min(3, "Le titre doit faire au moins 3 caractères"), title: z.string().min(3, "Le titre doit faire au moins 3 caractères"),
type: z.enum(["meme", "gif"]), type: z.enum(["meme", "gif", "video"]),
categoryId: z.string().optional(), categoryId: z.string().optional(),
tags: z.string().optional(), tags: z.string().optional(),
}); });
@@ -112,6 +112,16 @@ export default function UploadPage() {
return; return;
} }
setFile(selectedFile); setFile(selectedFile);
// Auto-détection du type
if (selectedFile.type === "image/gif") {
form.setValue("type", "gif");
} else if (selectedFile.type.startsWith("video/")) {
form.setValue("type", "video");
} else {
form.setValue("type", "meme");
}
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
setPreview(reader.result as string); setPreview(reader.result as string);
@@ -182,7 +192,7 @@ export default function UploadPage() {
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<FormLabel>Fichier (Image ou GIF)</FormLabel> <FormLabel>Fichier (Image, GIF ou Vidéo)</FormLabel>
{!preview ? ( {!preview ? (
<button <button
type="button" type="button"
@@ -194,25 +204,31 @@ export default function UploadPage() {
</div> </div>
<p className="font-medium">Cliquez pour choisir un fichier</p> <p className="font-medium">Cliquez pour choisir un fichier</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
PNG, JPG ou GIF jusqu'à 10Mo PNG, JPG, GIF, MP4 ou MOV jusqu'à 10Mo
</p> </p>
<input <input
id="file-upload" id="file-upload"
type="file" type="file"
className="hidden" className="hidden"
accept="image/*,.gif" accept="image/*,video/mp4,video/webm,video/quicktime,.gif"
onChange={handleFileChange} onChange={handleFileChange}
/> />
</button> </button>
) : ( ) : (
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800"> <div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
<div className="relative h-[400px] w-full"> <div className="relative h-[400px] w-full flex items-center justify-center">
<NextImage {file?.type.startsWith("video/") ? (
src={preview} <video src={preview} controls className="max-h-full max-w-full">
alt="Preview" <track kind="captions" />
fill </video>
className="object-contain" ) : (
/> <NextImage
src={preview}
alt="Preview"
fill
className="object-contain"
/>
)}
</div> </div>
<Button <Button
type="button" type="button"
@@ -260,6 +276,7 @@ export default function UploadPage() {
<SelectContent> <SelectContent>
<SelectItem value="meme">Image fixe</SelectItem> <SelectItem value="meme">Image fixe</SelectItem>
<SelectItem value="gif">GIF Animé</SelectItem> <SelectItem value="gif">GIF Animé</SelectItem>
<SelectItem value="video">Vidéo</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />

View File

@@ -74,6 +74,16 @@
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
} }
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
:root { :root {
--radius: 0.5rem; --radius: 0.5rem;
--background: oklch(0.9821 0 0); --background: oklch(0.9821 0 0);

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google"; import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { AudioProvider } from "@/providers/audio-provider";
import { AuthProvider } from "@/providers/auth-provider"; import { AuthProvider } from "@/providers/auth-provider";
import { ThemeProvider } from "@/providers/theme-provider"; import { ThemeProvider } from "@/providers/theme-provider";
import "./globals.css"; import "./globals.css";
@@ -71,8 +72,10 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider> <AuthProvider>
{children} <AudioProvider>
<Toaster /> {children}
<Toaster />
</AudioProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@@ -16,8 +16,10 @@ import {
TrendingUp, TrendingUp,
User as UserIcon, User as UserIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import * as React from "react"; import * as React from "react";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -47,6 +49,8 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
@@ -74,19 +78,43 @@ export function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { user, logout, isAuthenticated } = useAuth(); const { user, logout, isAuthenticated } = useAuth();
const { resolvedTheme } = useTheme();
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setMounted(true);
CategoryService.getAll().then(setCategories).catch(console.error); CategoryService.getAll().then(setCategories).catch(console.error);
}, []); }, []);
const logoSrc = React.useMemo(() => {
if (!mounted) return "/memegoat-color.svg";
return resolvedTheme === "dark"
? "/memegoat-white.svg"
: "/memegoat-black.svg";
}, [resolvedTheme, mounted]);
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader className="flex items-center justify-center py-4"> <SidebarHeader className="flex flex-row items-center justify-between py-4 group-data-[collapsible=icon]:justify-center">
<Link href="/" className="flex items-center gap-2 font-bold text-xl"> <Link
<div className="bg-primary text-primary-foreground p-1 rounded">🐐</div> href="/"
<span className="group-data-[collapsible=icon]:hidden">MemeGoat</span> className="flex items-center gap-2 font-bold text-xl overflow-hidden"
>
<div className="p-1 rounded shrink-0">
<Image
src={logoSrc}
alt="MemeGoat Logo"
width={32}
height={32}
className="w-8 h-8"
/>
</div>
<span className="group-data-[collapsible=icon]:hidden whitespace-nowrap">
MemeGoat
</span>
</Link> </Link>
<SidebarTrigger className="hidden md:flex group-data-[collapsible=icon]:hidden" />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
@@ -305,6 +333,7 @@ export function AppSidebar() {
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
<SidebarRail />
</Sidebar> </Sidebar>
); );
} }

View File

@@ -1,13 +1,21 @@
"use client"; "use client";
import { Edit, Eye, Heart, MoreHorizontal, Share2, Trash2 } from "lucide-react"; import {
Edit,
Eye,
Heart,
MoreHorizontal,
Share2,
Trash2,
Volume2,
VolumeX,
} from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -21,12 +29,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import { useAudio } from "@/providers/audio-provider";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service"; import { FavoriteService } from "@/services/favorite.service";
@@ -40,12 +43,34 @@ interface ContentCardProps {
export function ContentCard({ content, onUpdate }: ContentCardProps) { export function ContentCard({ content, onUpdate }: ContentCardProps) {
const { isAuthenticated, user } = useAuth(); const { isAuthenticated, user } = useAuth();
const { isGlobalMuted, activeVideoId, toggleGlobalMute, setActiveVideo } =
useAudio();
const router = useRouter(); const router = useRouter();
const [isLiked, setIsLiked] = React.useState(content.isLiked || false); const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount); const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const [editDialogOpen, setEditDialogOpen] = React.useState(false); const [editDialogOpen, setEditDialogOpen] = React.useState(false);
const isAuthor = user?.uuid === content.authorId; const isAuthor = user?.uuid === content.authorId;
const isVideo = !content.mimeType.startsWith("image/");
const isThisVideoActive = activeVideoId === content.id;
const isMuted = isGlobalMuted || (isVideo && !isThisVideoActive);
const videoRef = React.useRef<HTMLVideoElement>(null);
React.useEffect(() => {
if (videoRef.current) {
if (isThisVideoActive) {
const playPromise = videoRef.current.play();
if (playPromise !== undefined) {
playPromise.catch((_error) => {
// L'auto-lecture peut échouer si l'utilisateur n'a pas interagi avec la page
// On peut tenter de mettre en sourdine pour forcer la lecture si nécessaire
});
}
} else {
videoRef.current.pause();
}
}
}, [isThisVideoActive]);
React.useEffect(() => { React.useEffect(() => {
setIsLiked(content.isLiked || false); setIsLiked(content.isLiked || false);
@@ -77,6 +102,18 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
} }
}; };
const handleToggleMute = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isGlobalMuted) {
setActiveVideo(content.id);
} else if (isThisVideoActive) {
toggleGlobalMute();
} else {
setActiveVideo(content.id);
}
};
const handleUse = async (e: React.MouseEvent) => { const handleUse = async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -107,9 +144,9 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
return ( return (
<> <>
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow"> <Card className="overflow-hidden border-none gap-0 shadow-none bg-transparent">
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3"> <CardHeader className="p-3 flex flex-row items-center space-y-0 gap-3">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8 border">
<AvatarImage src={content.author.avatarUrl} /> <AvatarImage src={content.author.avatarUrl} />
<AvatarFallback> <AvatarFallback>
{content.author.username[0].toUpperCase()} {content.author.username[0].toUpperCase()}
@@ -118,13 +155,10 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
<div className="flex flex-col"> <div className="flex flex-col">
<Link <Link
href={`/user/${content.author.username}`} href={`/user/${content.author.username}`}
className="text-sm font-semibold hover:underline" className="text-sm font-bold hover:underline"
> >
{content.author.displayName || content.author.username} {content.author.username}
</Link> </Link>
<span className="text-xs text-muted-foreground">
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
</span>
</div> </div>
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
@@ -158,115 +192,115 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
</DropdownMenu> </DropdownMenu>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 aspect-square sm:aspect-video md:aspect-square flex items-center justify-center"> <CardContent className="p-0 relative bg-zinc-200 dark:bg-zinc-900 flex items-center justify-center overflow-hidden aspect-square w-full">
<Link href={`/meme/${content.slug}`} className="w-full h-full relative"> <Link
href={`/meme/${content.slug}`}
className="w-full h-full block relative"
>
{content.mimeType.startsWith("image/") ? ( {content.mimeType.startsWith("image/") ? (
<Image <Image
src={content.url} src={content.url}
alt={content.title} alt={content.title}
fill width={content.width || 1000}
className="object-contain" height={content.height || 1000}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" className="w-full h-full object-contain"
priority={false} priority={false}
/> />
) : ( ) : (
<video <video
ref={videoRef}
src={content.url} src={content.url}
controls={false} controls={false}
autoPlay autoPlay={isThisVideoActive}
muted muted={isMuted}
loop loop
playsInline playsInline
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
)} )}
</Link> </Link>
{isVideo && (
<Button
variant="ghost"
size="icon"
className="absolute bottom-2 right-2 h-8 w-8 rounded-full bg-black/50 text-white hover:bg-black/70 hover:text-white"
onClick={handleToggleMute}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
)}
</CardContent> </CardContent>
<CardFooter className="p-4 flex flex-col gap-4"> <CardFooter className="p-3 flex flex-col items-start gap-2">
<div className="w-full flex items-center justify-between"> <div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<TooltipProvider> <button
<Tooltip> type="button"
<TooltipTrigger asChild> onClick={handleLike}
<Button className={`transition-transform active:scale-125 ${isLiked ? "text-red-500" : "hover:text-muted-foreground"}`}
variant="ghost" >
size="sm" <Heart className={`h-6 w-6 ${isLiked ? "fill-current" : ""}`} />
className={`gap-1.5 h-8 px-2 ${isLiked ? "text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20" : ""}`} </button>
onClick={handleLike} <div className="flex items-center gap-1.5 text-muted-foreground">
> <Eye className="h-6 w-6" />
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} /> <span className="text-sm font-medium">{content.views}</span>
<span className="text-xs font-medium">{likesCount}</span> </div>
</Button> <button
</TooltipTrigger> type="button"
<TooltipContent>Liker</TooltipContent> onClick={() => {
</Tooltip> navigator.clipboard.writeText(
`${window.location.origin}/meme/${content.slug}`,
<Tooltip> );
<TooltipTrigger asChild> toast.success("Lien copié !");
<Button }}
variant="ghost" className="hover:text-muted-foreground"
size="sm" >
className="gap-1.5 h-8 px-2 cursor-default" <Share2 className="h-6 w-6" />
> </button>
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{content.views}</span>
</Button>
</TooltipTrigger>
<TooltipContent>Vues</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/meme/${content.slug}`,
);
toast.success("Lien copié !");
}}
>
<Share2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Partager</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
className="text-xs h-8 font-semibold" className="text-xs h-8 font-semibold rounded-full px-4"
onClick={handleUse} onClick={handleUse}
> >
Utiliser Utiliser
</Button> </Button>
</div> </div>
<div className="w-full space-y-2"> <div className="space-y-1">
<Link href={`/meme/${content.slug}`}> <p className="text-sm font-bold">{likesCount} J'aime</p>
<h3 className="font-semibold text-base line-clamp-2 hover:text-primary transition-colors"> <div className="text-sm leading-snug">
<Link
href={`/user/${content.author.username}`}
className="font-bold mr-2 hover:underline"
>
{content.author.username}
</Link>
<Link href={`/meme/${content.slug}`} className="break-words">
{content.title} {content.title}
</h3> </Link>
</Link> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1 mt-1">
{content.category && ( {content.tags.slice(0, 5).map((tag, _i) => (
<Badge variant="outline" className="text-[10px] py-0 px-2 bg-muted/50"> <Link
{content.category.name}
</Badge>
)}
{content.tags.slice(0, 3).map((tag, _i) => (
<Badge
key={typeof tag === "string" ? tag : tag.id} key={typeof tag === "string" ? tag : tag.id}
variant="secondary" href={`/?tag=${typeof tag === "string" ? tag : tag.slug}`}
className="text-[10px] py-0 px-2" className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
> >
#{typeof tag === "string" ? tag : tag.name} #{typeof tag === "string" ? tag : tag.name}
</Badge> </Link>
))} ))}
</div> </div>
<p className="text-[10px] text-muted-foreground uppercase mt-1">
{new Date(content.createdAt).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
})}
</p>
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll"; import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
import { ContentCard } from "@/components/content-card"; import { ContentCard } from "@/components/content-card";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useAudio } from "@/providers/audio-provider";
import type { Content, PaginatedResponse } from "@/types/content"; import type { Content, PaginatedResponse } from "@/types/content";
interface ContentListProps { interface ContentListProps {
@@ -15,10 +16,48 @@ interface ContentListProps {
} }
export function ContentList({ fetchFn, title }: ContentListProps) { export function ContentList({ fetchFn, title }: ContentListProps) {
const { setActiveVideo } = useAudio();
const [contents, setContents] = React.useState<Content[]>([]); const [contents, setContents] = React.useState<Content[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [offset, setOffset] = React.useState(0); const [offset, setOffset] = React.useState(0);
const [hasMore, setHasMore] = React.useState(true); const [hasMore, setHasMore] = React.useState(true);
const containerRef = React.useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: On a besoin de contents pour ré-attacher l'observer quand la liste change
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
// On cherche l'entrée avec le plus grand ratio d'intersection parmi celles qui dépassent le seuil
let bestEntry: IntersectionObserverEntry | null = null;
let maxRatio = 0;
for (const entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio;
bestEntry = entry;
}
}
if (bestEntry && maxRatio >= 0.6) {
const contentId = bestEntry.target.getAttribute("data-content-id");
if (contentId) {
setActiveVideo(contentId);
}
}
},
{
threshold: [0, 0.2, 0.4, 0.6, 0.8, 1.0], // Plusieurs seuils pour une détection plus fine du "meilleur"
root: containerRef.current,
},
);
const elements = containerRef.current?.querySelectorAll("[data-content-id]");
elements?.forEach((el) => {
observer.observe(el);
});
return () => observer.disconnect();
}, [setActiveVideo, contents]);
const fetchInitial = React.useCallback(async () => { const fetchInitial = React.useCallback(async () => {
setLoading(true); setLoading(true);
@@ -51,7 +90,11 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
offset: offset + 10, offset: offset + 10,
}); });
setContents((prev) => [...prev, ...response.data]); setContents((prev) => {
const newIds = new Set(response.data.map((item) => item.id));
const filteredPrev = prev.filter((item) => !newIds.has(item.id));
return [...filteredPrev, ...response.data];
});
setOffset((prev) => prev + 10); setOffset((prev) => prev + 10);
setHasMore(response.data.length === 10); setHasMore(response.data.length === 10);
} catch (error) { } catch (error) {
@@ -68,11 +111,20 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
}); });
return ( return (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8"> <div
{title && <h1 className="text-2xl font-bold">{title}</h1>} ref={containerRef}
<div className="flex flex-col gap-6"> className="mx-auto px-4 h-screen flex flex-col justify-start items-center overflow-y-auto snap-y snap-mandatory no-scrollbar"
>
{title && <h1 className="text-2xl font-bold py-8">{title}</h1>}
<div className="max-w-xl flex flex-col justify-start items-center">
{contents.map((content) => ( {contents.map((content) => (
<ContentCard key={content.id} content={content} onUpdate={fetchInitial} /> <div
key={content.id}
data-content-id={content.id}
className="w-full snap-start snap-always py-4"
>
<ContentCard content={content} onUpdate={fetchInitial} />
</div>
))} ))}
</div> </div>

View File

@@ -1,12 +1,18 @@
"use client"; "use client";
import { Filter, Search } from "lucide-react"; import { ChevronLeft, ChevronRight, Filter, Search } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CategoryService } from "@/services/category.service"; import { CategoryService } from "@/services/category.service";
import { TagService } from "@/services/tag.service"; import { TagService } from "@/services/tag.service";
import type { Category, Tag } from "@/types/content"; import type { Category, Tag } from "@/types/content";
@@ -16,10 +22,24 @@ export function SearchSidebar() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname(); const pathname = usePathname();
const [isCollapsed, setIsCollapsed] = React.useState(true);
const [categories, setCategories] = React.useState<Category[]>([]); const [categories, setCategories] = React.useState<Category[]>([]);
const [popularTags, setPopularTags] = React.useState<Tag[]>([]); const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
const [query, setQuery] = React.useState(searchParams.get("query") || ""); const [query, setQuery] = React.useState(searchParams.get("query") || "");
// Ouvrir automatiquement si des filtres sont actifs
React.useEffect(() => {
const hasFilters =
searchParams.has("query") ||
searchParams.has("category") ||
searchParams.has("tag") ||
searchParams.get("sort") !== "trend";
if (hasFilters) {
setIsCollapsed(false);
}
}, [searchParams]);
React.useEffect(() => { React.useEffect(() => {
CategoryService.getAll().then(setCategories).catch(console.error); CategoryService.getAll().then(setCategories).catch(console.error);
TagService.getAll({ limit: 10, sort: "popular" }) TagService.getAll({ limit: 10, sort: "popular" })
@@ -51,98 +71,149 @@ export function SearchSidebar() {
const currentCategory = searchParams.get("category"); const currentCategory = searchParams.get("category");
return ( return (
<aside className="hidden lg:flex flex-col w-80 border-l bg-background"> <aside
<div className="p-4 border-b"> className={`hidden lg:flex flex-col border-l bg-background transition-all duration-300 relative ${
<h2 className="font-semibold mb-4">Rechercher</h2> isCollapsed ? "w-12" : "w-80"
<form onSubmit={handleSearch} className="relative"> }`}
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> >
<Input <Button
type="search" variant="outline"
placeholder="Rechercher des mèmes..." size="icon"
className="pl-8" className="absolute -left-4 top-20 h-8 w-8 rounded-full bg-background shadow-md z-50 hover:bg-accent"
value={query} onClick={() => setIsCollapsed(!isCollapsed)}
onChange={(e) => setQuery(e.target.value)} >
/> {isCollapsed ? (
</form> <ChevronLeft className="h-4 w-4" />
</div> ) : (
<ScrollArea className="flex-1 p-4"> <ChevronRight className="h-4 w-4" />
<div className="space-y-6"> )}
<div> </Button>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Filter className="h-4 w-4" /> {isCollapsed ? (
Filtres <div className="flex flex-col items-center py-4 gap-4 overflow-hidden">
</h3> <Tooltip>
<div className="space-y-4"> <TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(false)}
>
<Search className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Rechercher</TooltipContent>
</Tooltip>
<Separator />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(false)}
>
<Filter className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Filtres</TooltipContent>
</Tooltip>
</div>
) : (
<>
<div className="p-4 border-b">
<h2 className="font-semibold mb-4">Rechercher</h2>
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des mèmes..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</div>
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
<div> <div>
<p className="text-xs text-muted-foreground mb-2">Trier par</p> <h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<div className="flex flex-wrap gap-2"> <Filter className="h-4 w-4" />
<Badge Filtres
variant={currentSort === "trend" ? "default" : "outline"} </h3>
className="cursor-pointer" <div className="space-y-4">
onClick={() => updateSearch("sort", "trend")} <div>
> <p className="text-xs text-muted-foreground mb-2">Trier par</p>
Tendances <div className="flex flex-wrap gap-2">
</Badge> <Badge
<Badge variant={currentSort === "trend" ? "default" : "outline"}
variant={currentSort === "recent" ? "default" : "outline"} className="cursor-pointer"
className="cursor-pointer" onClick={() => updateSearch("sort", "trend")}
onClick={() => updateSearch("sort", "recent")} >
> Tendances
Récent </Badge>
</Badge> <Badge
variant={currentSort === "recent" ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("sort", "recent")}
>
Récent
</Badge>
</div>
</div>
<Separator />
<div>
<p className="text-xs text-muted-foreground mb-2">Catégories</p>
<div className="flex flex-wrap gap-2">
<Badge
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
<Badge
key={cat.id}
variant={currentCategory === cat.slug ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", cat.slug)}
>
{cat.name}
</Badge>
))}
</div>
</div>
</div> </div>
</div> </div>
<Separator />
<div> <div>
<p className="text-xs text-muted-foreground mb-2">Catégories</p> <h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge {popularTags.map((tag) => (
variant={!currentCategory ? "default" : "outline"}
className="cursor-pointer"
onClick={() => updateSearch("category", null)}
>
Tout
</Badge>
{categories.map((cat) => (
<Badge <Badge
key={cat.id} key={tag.id}
variant={currentCategory === cat.slug ? "default" : "outline"} variant={
className="cursor-pointer" searchParams.get("tag") === tag.name ? "default" : "outline"
onClick={() => updateSearch("category", cat.slug)} }
className="cursor-pointer hover:bg-secondary"
onClick={() =>
updateSearch(
"tag",
searchParams.get("tag") === tag.name ? null : tag.name,
)
}
> >
{cat.name} #{tag.name}
</Badge> </Badge>
))} ))}
{popularTags.length === 0 && (
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </ScrollArea>
</>
<div> )}
<h3 className="text-sm font-medium mb-3">Tags populaires</h3>
<div className="flex flex-wrap gap-2">
{popularTags.map((tag) => (
<Badge
key={tag.id}
variant={searchParams.get("tag") === tag.name ? "default" : "outline"}
className="cursor-pointer hover:bg-secondary"
onClick={() =>
updateSearch(
"tag",
searchParams.get("tag") === tag.name ? null : tag.name,
)
}
>
#{tag.name}
</Badge>
))}
{popularTags.length === 0 && (
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
)}
</div>
</div>
</div>
</ScrollArea>
</aside> </aside>
); );
} }

View File

@@ -0,0 +1,45 @@
"use client";
import type React from "react";
import { createContext, useCallback, useContext, useState } from "react";
interface AudioContextType {
isGlobalMuted: boolean;
activeVideoId: string | null;
toggleGlobalMute: () => void;
setActiveVideo: (id: string | null) => void;
}
const AudioContext = createContext<AudioContextType | undefined>(undefined);
export function AudioProvider({ children }: { children: React.ReactNode }) {
const [isGlobalMuted, setIsGlobalMuted] = useState(true);
const [activeVideoId, setActiveVideoId] = useState<string | null>(null);
const toggleGlobalMute = useCallback(() => {
setIsGlobalMuted((prev) => !prev);
}, []);
const setActiveVideo = useCallback((id: string | null) => {
setActiveVideoId(id);
if (id !== null) {
setIsGlobalMuted(false);
}
}, []);
return (
<AudioContext.Provider
value={{ isGlobalMuted, activeVideoId, toggleGlobalMute, setActiveVideo }}
>
{children}
</AudioContext.Provider>
);
}
export function useAudio() {
const context = useContext(AudioContext);
if (context === undefined) {
throw new Error("useAudio must be used within an AudioProvider");
}
return context;
}

View File

@@ -7,7 +7,7 @@ export interface Content {
description?: string; description?: string;
url: string; url: string;
thumbnailUrl?: string; thumbnailUrl?: string;
type: "meme" | "gif"; type: "meme" | "gif" | "video";
mimeType: string; mimeType: string;
size: number; size: number;
width?: number; width?: number;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/source", "name": "@memegoat/source",
"version": "1.4.1", "version": "1.7.0",
"description": "", "description": "",
"scripts": { "scripts": {
"version:get": "cmake -P version.cmake GET", "version:get": "cmake -P version.cmake GET",

20791
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff