Compare commits
103 Commits
329a150ff8
...
v1.9.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30bcfdb436
|
||
|
|
0b4753c47b
|
||
|
|
69b90849fd
|
||
|
|
f2950ecf86
|
||
|
|
1e17308aab
|
||
|
|
ca4b594828
|
||
|
|
2ea16773c8
|
||
|
|
616d7f76d7
|
||
|
|
f882a70343
|
||
|
|
779bb5c112
|
||
|
|
5753477717
|
||
|
|
7615ec670e
|
||
|
|
40cfff683d
|
||
|
|
bb52782226
|
||
|
|
6a70274623
|
||
|
|
aabc615b89
|
||
|
|
f9b202375f
|
||
|
|
6398965f16
|
||
|
|
9e9b1db012
|
||
|
|
62bf03d07a
|
||
|
|
c83ba6eb7d
|
||
|
|
05a05a1940
|
||
|
|
7c065a2fb9
|
||
|
|
001cdaff8f
|
||
|
|
0eb940c5ce
|
||
|
|
f0617c8ba5
|
||
|
|
27ea6fa413
|
||
|
|
e2146f4502
|
||
|
|
484b775923
|
||
|
|
5b05a14932
|
||
|
|
2704f7d5c5
|
||
|
|
d271cc215b
|
||
|
|
9eb5a60fb2
|
||
|
|
950646a426
|
||
|
|
a9b80e66cd
|
||
|
|
307655371d
|
||
|
|
8eb0cba050
|
||
|
|
50787c9357
|
||
|
|
0972ed951f
|
||
|
|
f852835c59
|
||
|
|
2c18fd1c1a
|
||
|
|
6d80795e44
|
||
|
|
ace438be6b
|
||
|
|
ea1afa7688
|
||
|
|
0976850c0c
|
||
|
|
ed3ed66cab
|
||
|
|
46ffdd809c
|
||
|
|
2dcd277347
|
||
|
|
9486803aeb
|
||
|
|
1e0bb03182
|
||
|
|
f1d1359dcb
|
||
|
|
7b76942795
|
||
|
|
1be8571f26
|
||
|
|
29b1db4aed
|
||
|
|
9db3067721
|
||
|
|
27f8c7148a
|
||
|
|
209711195b
|
||
|
|
fafdaee668
|
||
|
|
01117aad6d
|
||
|
|
e73ae80fc5
|
||
|
|
9ccbd2ceb1
|
||
|
|
3edf5cc070
|
||
|
|
2d670ad9cf
|
||
|
|
fc2f5214b1
|
||
|
|
aa17c57e26
|
||
|
|
004021ff84
|
||
|
|
586d827552
|
||
|
|
17fc8d4b68
|
||
|
|
4a66676fcb
|
||
|
|
48db40b3d4
|
||
|
|
c32d4e7203
|
||
|
|
9b7c2c8e5b
|
||
|
|
0584c46190
|
||
|
|
13ccdbc2ab
|
||
| a4d0c6aa8c | |||
|
|
ba0234fd13
|
||
|
|
81461d04e9
|
||
| c4e6be4452 | |||
| 18288cf8f3 | |||
| 3ffc5b6fde | |||
| 5413774cf4 | |||
| e342eacc69 | |||
|
|
60643f6aa8
|
||
|
|
929dd74ec1
|
||
|
|
87534c0596
|
||
|
|
fa673d0f80
|
||
|
|
8df6d15b19
|
||
|
|
0144421f03
|
||
|
|
df9a6c6f36
|
||
|
|
15426a9e18
|
||
|
|
a28844e9b7
|
||
|
|
ae916931f6
|
||
|
|
e4dc5dd10b
|
||
|
|
878c35cbcd
|
||
|
|
8cf0036248
|
||
|
|
c389024f59
|
||
|
|
bbdbe58af5
|
||
|
|
5951e41eb5
|
||
|
|
7442236e8d
|
||
|
|
3ef7292287
|
||
|
|
f1a571196d
|
||
|
|
f4cd20a010
|
||
|
|
988eacc281
|
BIN
REAC_CDA_V04_24052023.pdf
Normal file
BIN
REAC_CDA_V04_24052023.pdf
Normal file
Binary file not shown.
26
README.md
26
README.md
@@ -59,12 +59,28 @@ Pour approfondir vos connaissances techniques sur le projet :
|
||||
|
||||
## Comment l'utiliser ?
|
||||
|
||||
### Installation locale
|
||||
### Déploiement en Production
|
||||
|
||||
1. Clonez le dépôt.
|
||||
2. Installez les dépendances avec `pnpm install`.
|
||||
3. Configurez les variables d'environnement (voir `.env.example`).
|
||||
4. Lancez les services via Docker ou manuellement.
|
||||
Le projet est prêt pour la production via Docker Compose.
|
||||
|
||||
1. **Prérequis** : Docker et Docker Compose installés.
|
||||
2. **Variables d'environnement** : Copiez `.env.example` en `.env.prod` et ajustez les valeurs (clés secrètes, hosts, Sentry DSN, etc.).
|
||||
3. **Lancement** :
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
4. **Services inclus** :
|
||||
- **Frontend** : Next.js en mode standalone optimisé.
|
||||
- **Backend** : NestJS avec clustering et monitoring Sentry.
|
||||
- **Caddy** : Gestion automatique du SSL/TLS.
|
||||
- **ClamAV** : Scan antivirus en temps réel des médias.
|
||||
- **Redis** : Cache, sessions et limitation de débit (Throttling/Bot detection).
|
||||
- **MinIO** : Stockage compatible S3.
|
||||
|
||||
### Sécurité et Performance
|
||||
- **Transcodage Auto** : Toutes les images sont converties en WebP et les vidéos en WebM pour minimiser la bande passante.
|
||||
- **Bot Detection** : Système intégré de détection et de bannissement automatique des crawlers malveillants via Redis.
|
||||
- **Monitoring** : Tracking d'erreurs et profilage de performance via Sentry (Node.js et Next.js).
|
||||
|
||||
### Clés API
|
||||
|
||||
|
||||
756
backend.plantuml
Normal file
756
backend.plantuml
Normal 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
|
||||
1
backend/.migrations/0007_melodic_synch.sql
Normal file
1
backend/.migrations/0007_melodic_synch.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."content_type" ADD VALUE 'video';
|
||||
54
backend/.migrations/0008_bitter_darwin.sql
Normal file
54
backend/.migrations/0008_bitter_darwin.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
CREATE TABLE "comment_likes" (
|
||||
"comment_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "comment_likes_comment_id_user_id_pk" PRIMARY KEY("comment_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"content_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"text" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "conversation_participants" (
|
||||
"conversation_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "conversation_participants_conversation_id_user_id_pk" PRIMARY KEY("conversation_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "conversations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"conversation_id" uuid NOT NULL,
|
||||
"sender_id" uuid NOT NULL,
|
||||
"text" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"read_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversation_participants" ADD CONSTRAINT "conversation_participants_user_id_users_uuid_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_uuid_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("uuid") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "comments_content_id_idx" ON "comments" USING btree ("content_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_user_id_idx" ON "comments" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
|
||||
CREATE INDEX "messages_conversation_id_idx" ON "messages" USING btree ("conversation_id");--> statement-breakpoint
|
||||
CREATE INDEX "messages_sender_id_idx" ON "messages" USING btree ("sender_id");
|
||||
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
1653
backend/.migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2080
backend/.migrations/meta/0008_snapshot.json
Normal file
2080
backend/.migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,20 @@
|
||||
"when": 1768423315172,
|
||||
"tag": "0006_friendly_adam_warlock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1769605995410,
|
||||
"tag": "0007_melodic_synch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1769696731978,
|
||||
"tag": "0008_bitter_darwin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,8 @@ ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /usr/src/app
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
@@ -13,13 +15,13 @@ COPY documentation/package.json ./documentation/
|
||||
|
||||
# Utilisation du cache pour pnpm et installation figée
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile --force
|
||||
|
||||
COPY . .
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile --force
|
||||
|
||||
RUN pnpm run --filter @memegoat/backend build
|
||||
RUN pnpm deploy --filter=@memegoat/backend --prod --legacy /app
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/backend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.9.4",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -36,8 +36,10 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.12",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.12",
|
||||
"@noble/post-quantum": "^0.5.4",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@sentry/nestjs": "^10.32.1",
|
||||
@@ -48,6 +50,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"helmet": "^8.1.0",
|
||||
@@ -61,23 +64,12 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.5",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
@@ -89,9 +81,21 @@
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CategoriesModule } from "./categories/categories.module";
|
||||
import { CommentsModule } from "./comments/comments.module";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { CrawlerDetectionMiddleware } from "./common/middlewares/crawler-detection.middleware";
|
||||
import { HTTPLoggerMiddleware } from "./common/middlewares/http-logger.middleware";
|
||||
@@ -21,6 +22,8 @@ import { FavoritesModule } from "./favorites/favorites.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { MailModule } from "./mail/mail.module";
|
||||
import { MediaModule } from "./media/media.module";
|
||||
import { MessagesModule } from "./messages/messages.module";
|
||||
import { RealtimeModule } from "./realtime/realtime.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { S3Module } from "./s3/s3.module";
|
||||
import { SessionsModule } from "./sessions/sessions.module";
|
||||
@@ -37,12 +40,15 @@ import { UsersModule } from "./users/users.module";
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
CategoriesModule,
|
||||
CommentsModule,
|
||||
ContentsModule,
|
||||
FavoritesModule,
|
||||
TagsModule,
|
||||
MediaModule,
|
||||
MessagesModule,
|
||||
SessionsModule,
|
||||
ReportsModule,
|
||||
RealtimeModule,
|
||||
ApiKeysModule,
|
||||
AdminModule,
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("AuthService", () => {
|
||||
const dto = {
|
||||
username: "test",
|
||||
email: "test@example.com",
|
||||
password: "password",
|
||||
password: "Password1!",
|
||||
};
|
||||
mockHashingService.hashPassword.mockResolvedValue("hashed-password");
|
||||
mockHashingService.hashEmail.mockResolvedValue("hashed-email");
|
||||
@@ -165,7 +165,7 @@ describe("AuthService", () => {
|
||||
|
||||
describe("login", () => {
|
||||
it("should login a user", async () => {
|
||||
const dto = { email: "test@example.com", password: "password" };
|
||||
const dto = { email: "test@example.com", password: "Password1!" };
|
||||
const user = {
|
||||
uuid: "user-id",
|
||||
username: "test",
|
||||
|
||||
@@ -136,6 +136,7 @@ export class AuthService {
|
||||
const accessToken = await this.jwtService.generateJwt({
|
||||
sub: user.uuid,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const session = await this.sessionsService.createSession(
|
||||
@@ -178,6 +179,7 @@ export class AuthService {
|
||||
const accessToken = await this.jwtService.generateJwt({
|
||||
sub: user.uuid,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const session = await this.sessionsService.createSession(
|
||||
@@ -205,6 +207,7 @@ export class AuthService {
|
||||
const accessToken = await this.jwtService.generateJwt({
|
||||
sub: user.uuid,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
@@ -10,6 +11,10 @@ export class RegisterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
@Matches(/^[a-z0-9_]+$/, {
|
||||
message:
|
||||
"username must contain only lowercase letters, numbers, and underscores",
|
||||
})
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@@ -21,5 +26,15 @@ export class RegisterDto {
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@Matches(/[A-Z]/, {
|
||||
message: "password must contain at least one uppercase letter",
|
||||
})
|
||||
@Matches(/[a-z]/, {
|
||||
message: "password must contain at least one lowercase letter",
|
||||
})
|
||||
@Matches(/[0-9]/, { message: "password must contain at least one number" })
|
||||
@Matches(/[^A-Za-z0-9]/, {
|
||||
message: "password must contain at least one special character",
|
||||
})
|
||||
password!: string;
|
||||
}
|
||||
|
||||
80
backend/src/comments/comments.controller.ts
Normal file
80
backend/src/comments/comments.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { getSessionOptions } from "../auth/session.config";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { JwtService } from "../crypto/services/jwt.service";
|
||||
import { CommentsService } from "./comments.service";
|
||||
import { CreateCommentDto } from "./dto/create-comment.dto";
|
||||
|
||||
@Controller()
|
||||
export class CommentsController {
|
||||
constructor(
|
||||
private readonly commentsService: CommentsService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get("contents/:contentId/comments")
|
||||
async findAllByContentId(
|
||||
@Param("contentId") contentId: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
// Tentative de récupération de l'utilisateur pour isLiked (optionnel)
|
||||
let userId: string | undefined;
|
||||
try {
|
||||
const session = await getIronSession<any>(
|
||||
req,
|
||||
req.res,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
if (session.accessToken) {
|
||||
const payload = await this.jwtService.verifyJwt(session.accessToken);
|
||||
userId = payload.sub;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignorer les erreurs de session
|
||||
}
|
||||
|
||||
return this.commentsService.findAllByContentId(contentId, userId);
|
||||
}
|
||||
|
||||
@Post("contents/:contentId/comments")
|
||||
@UseGuards(AuthGuard)
|
||||
create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("contentId") contentId: string,
|
||||
@Body() dto: CreateCommentDto,
|
||||
) {
|
||||
return this.commentsService.create(req.user.sub, contentId, dto);
|
||||
}
|
||||
|
||||
@Delete("comments/:id")
|
||||
@UseGuards(AuthGuard)
|
||||
remove(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
|
||||
const isAdmin = req.user.role === "admin" || req.user.role === "moderator";
|
||||
return this.commentsService.remove(req.user.sub, id, isAdmin);
|
||||
}
|
||||
|
||||
@Post("comments/:id/like")
|
||||
@UseGuards(AuthGuard)
|
||||
like(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
|
||||
return this.commentsService.like(req.user.sub, id);
|
||||
}
|
||||
|
||||
@Delete("comments/:id/like")
|
||||
@UseGuards(AuthGuard)
|
||||
unlike(@Req() req: AuthenticatedRequest, @Param("id") id: string) {
|
||||
return this.commentsService.unlike(req.user.sub, id);
|
||||
}
|
||||
}
|
||||
22
backend/src/comments/comments.module.ts
Normal file
22
backend/src/comments/comments.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { ContentsModule } from "../contents/contents.module";
|
||||
import { RealtimeModule } from "../realtime/realtime.module";
|
||||
import { S3Module } from "../s3/s3.module";
|
||||
import { CommentsController } from "./comments.controller";
|
||||
import { CommentsService } from "./comments.service";
|
||||
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
|
||||
import { CommentsRepository } from "./repositories/comments.repository";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
S3Module,
|
||||
RealtimeModule,
|
||||
forwardRef(() => ContentsModule),
|
||||
],
|
||||
controllers: [CommentsController],
|
||||
providers: [CommentsService, CommentsRepository, CommentLikesRepository],
|
||||
exports: [CommentsService],
|
||||
})
|
||||
export class CommentsModule {}
|
||||
151
backend/src/comments/comments.service.spec.ts
Normal file
151
backend/src/comments/comments.service.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ForbiddenException, NotFoundException } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { CommentsService } from "./comments.service";
|
||||
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
|
||||
import { CommentsRepository } from "./repositories/comments.repository";
|
||||
|
||||
describe("CommentsService", () => {
|
||||
let service: CommentsService;
|
||||
let repository: CommentsRepository;
|
||||
|
||||
const mockCommentsRepository = {
|
||||
create: jest.fn(),
|
||||
findAllByContentId: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findOneEnriched: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCommentLikesRepository = {
|
||||
addLike: jest.fn(),
|
||||
removeLike: jest.fn(),
|
||||
countByCommentId: jest.fn(),
|
||||
isLikedByUser: jest.fn(),
|
||||
};
|
||||
|
||||
const mockContentsRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockS3Service = {
|
||||
getPublicUrl: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEventsGateway = {
|
||||
sendToContent: jest.fn(),
|
||||
sendToUser: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CommentsService,
|
||||
{ provide: CommentsRepository, useValue: mockCommentsRepository },
|
||||
{ provide: CommentLikesRepository, useValue: mockCommentLikesRepository },
|
||||
{ provide: ContentsRepository, useValue: mockContentsRepository },
|
||||
{ provide: S3Service, useValue: mockS3Service },
|
||||
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CommentsService>(CommentsService);
|
||||
repository = module.get<CommentsRepository>(CommentsRepository);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a comment", async () => {
|
||||
const userId = "user1";
|
||||
const contentId = "content1";
|
||||
const dto = { text: "Nice meme", parentId: undefined };
|
||||
const createdComment = { id: "c1", ...dto, user: { username: "u1" } };
|
||||
mockCommentsRepository.create.mockResolvedValue(createdComment);
|
||||
mockCommentsRepository.findOneEnriched.mockResolvedValue(createdComment);
|
||||
mockCommentLikesRepository.countByCommentId.mockResolvedValue(0);
|
||||
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(false);
|
||||
|
||||
const result = await service.create(userId, contentId, dto);
|
||||
expect(result.id).toBe("c1");
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
userId,
|
||||
contentId,
|
||||
text: dto.text,
|
||||
parentId: undefined,
|
||||
});
|
||||
expect(mockEventsGateway.sendToContent).toHaveBeenCalledWith(
|
||||
contentId,
|
||||
"new_comment",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAllByContentId", () => {
|
||||
it("should return comments for a content", async () => {
|
||||
mockCommentsRepository.findAllByContentId.mockResolvedValue([
|
||||
{ id: "c1", user: { avatarUrl: "path" } },
|
||||
]);
|
||||
mockCommentLikesRepository.countByCommentId.mockResolvedValue(5);
|
||||
mockCommentLikesRepository.isLikedByUser.mockResolvedValue(true);
|
||||
mockS3Service.getPublicUrl.mockReturnValue("url");
|
||||
|
||||
const result = await service.findAllByContentId("content1", "u1");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].likesCount).toBe(5);
|
||||
expect(result[0].isLiked).toBe(true);
|
||||
expect(result[0].user.avatarUrl).toBe("url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should remove comment if owner", async () => {
|
||||
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
|
||||
await service.remove("u1", "c1");
|
||||
expect(repository.delete).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("should remove comment if admin", async () => {
|
||||
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
|
||||
await service.remove("other", "c1", true);
|
||||
expect(repository.delete).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if comment does not exist", async () => {
|
||||
mockCommentsRepository.findOne.mockResolvedValue(null);
|
||||
await expect(service.remove("u1", "c1")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException if not owner and not admin", async () => {
|
||||
mockCommentsRepository.findOne.mockResolvedValue({ userId: "u1" });
|
||||
await expect(service.remove("other", "c1")).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("like", () => {
|
||||
it("should add like", async () => {
|
||||
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
|
||||
await service.like("u1", "c1");
|
||||
expect(mockCommentLikesRepository.addLike).toHaveBeenCalledWith("c1", "u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlike", () => {
|
||||
it("should remove like", async () => {
|
||||
mockCommentsRepository.findOne.mockResolvedValue({ id: "c1" });
|
||||
await service.unlike("u1", "c1");
|
||||
expect(mockCommentLikesRepository.removeLike).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
"u1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
backend/src/comments/comments.service.ts
Normal file
177
backend/src/comments/comments.service.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ContentsRepository } from "../contents/repositories/contents.repository";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import type { CreateCommentDto } from "./dto/create-comment.dto";
|
||||
import { CommentLikesRepository } from "./repositories/comment-likes.repository";
|
||||
import { CommentsRepository } from "./repositories/comments.repository";
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly commentsRepository: CommentsRepository,
|
||||
private readonly commentLikesRepository: CommentLikesRepository,
|
||||
@Inject(forwardRef(() => ContentsRepository))
|
||||
private readonly contentsRepository: ContentsRepository,
|
||||
private readonly s3Service: S3Service,
|
||||
private readonly eventsGateway: EventsGateway,
|
||||
) {}
|
||||
|
||||
async create(userId: string, contentId: string, dto: CreateCommentDto) {
|
||||
const comment = await this.commentsRepository.create({
|
||||
userId,
|
||||
contentId,
|
||||
text: dto.text,
|
||||
parentId: dto.parentId,
|
||||
});
|
||||
|
||||
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket
|
||||
const enrichedComment = await this.findOneEnriched(comment.id, userId);
|
||||
if (!enrichedComment) return null;
|
||||
|
||||
// Notifier les autres utilisateurs sur ce contenu (room de contenu)
|
||||
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment);
|
||||
|
||||
// Notifications ciblées
|
||||
try {
|
||||
// 1. Notifier l'auteur du post
|
||||
const content = await this.contentsRepository.findOne(contentId);
|
||||
if (content && content.userId !== userId) {
|
||||
this.eventsGateway.sendToUser(content.userId, "notification", {
|
||||
type: "comment",
|
||||
userId: userId,
|
||||
username: enrichedComment.user.username,
|
||||
contentId: contentId,
|
||||
commentId: comment.id,
|
||||
text: `a commenté votre post : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Si c'est une réponse, notifier l'auteur du commentaire parent
|
||||
if (dto.parentId) {
|
||||
const parentComment = await this.commentsRepository.findOne(dto.parentId);
|
||||
if (
|
||||
parentComment &&
|
||||
parentComment.userId !== userId &&
|
||||
(!content || parentComment.userId !== content.userId)
|
||||
) {
|
||||
this.eventsGateway.sendToUser(parentComment.userId, "notification", {
|
||||
type: "reply",
|
||||
userId: userId,
|
||||
username: enrichedComment.user.username,
|
||||
contentId: contentId,
|
||||
commentId: comment.id,
|
||||
text: `a répondu à votre commentaire : "${dto.text.substring(0, 30)}${dto.text.length > 30 ? "..." : ""}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send notification:", error);
|
||||
}
|
||||
|
||||
return enrichedComment;
|
||||
}
|
||||
|
||||
async findOneEnriched(commentId: string, currentUserId?: string) {
|
||||
const comment = await this.commentsRepository.findOneEnriched(commentId);
|
||||
if (!comment) return null;
|
||||
|
||||
const [likesCount, isLiked] = await Promise.all([
|
||||
this.commentLikesRepository.countByCommentId(comment.id),
|
||||
currentUserId
|
||||
? this.commentLikesRepository.isLikedByUser(comment.id, currentUserId)
|
||||
: Promise.resolve(false),
|
||||
]);
|
||||
|
||||
return {
|
||||
...comment,
|
||||
likesCount,
|
||||
isLiked,
|
||||
user: {
|
||||
...comment.user,
|
||||
avatarUrl: comment.user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findAllByContentId(contentId: string, userId?: string) {
|
||||
const comments = await this.commentsRepository.findAllByContentId(contentId);
|
||||
|
||||
return Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
const [likesCount, isLiked] = await Promise.all([
|
||||
this.commentLikesRepository.countByCommentId(comment.id),
|
||||
userId
|
||||
? this.commentLikesRepository.isLikedByUser(comment.id, userId)
|
||||
: Promise.resolve(false),
|
||||
]);
|
||||
|
||||
return {
|
||||
...comment,
|
||||
likesCount,
|
||||
isLiked,
|
||||
user: {
|
||||
...comment.user,
|
||||
avatarUrl: comment.user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(comment.user.avatarUrl)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async remove(userId: string, commentId: string, isAdmin = false) {
|
||||
const comment = await this.commentsRepository.findOne(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException("Comment not found");
|
||||
}
|
||||
|
||||
if (!isAdmin && comment.userId !== userId) {
|
||||
throw new ForbiddenException("You cannot delete this comment");
|
||||
}
|
||||
|
||||
await this.commentsRepository.delete(commentId);
|
||||
}
|
||||
|
||||
async like(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findOne(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException("Comment not found");
|
||||
}
|
||||
await this.commentLikesRepository.addLike(commentId, userId);
|
||||
|
||||
// Notifier l'auteur du commentaire
|
||||
if (comment.userId !== userId) {
|
||||
try {
|
||||
const liker = await this.findOneEnriched(commentId, userId);
|
||||
this.eventsGateway.sendToUser(comment.userId, "notification", {
|
||||
type: "like_comment",
|
||||
userId: userId,
|
||||
username: liker?.user.username,
|
||||
contentId: comment.contentId,
|
||||
commentId: commentId,
|
||||
text: "a aimé votre commentaire",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send like notification:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async unlike(userId: string, commentId: string) {
|
||||
const comment = await this.commentsRepository.findOne(commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException("Comment not found");
|
||||
}
|
||||
await this.commentLikesRepository.removeLike(commentId, userId);
|
||||
}
|
||||
}
|
||||
18
backend/src/comments/dto/create-comment.dto.ts
Normal file
18
backend/src/comments/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(1000)
|
||||
text!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { commentLikes } from "../../database/schemas/comment_likes";
|
||||
|
||||
@Injectable()
|
||||
export class CommentLikesRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async addLike(commentId: string, userId: string) {
|
||||
await this.databaseService.db
|
||||
.insert(commentLikes)
|
||||
.values({ commentId, userId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeLike(commentId: string, userId: string) {
|
||||
await this.databaseService.db
|
||||
.delete(commentLikes)
|
||||
.where(
|
||||
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
|
||||
);
|
||||
}
|
||||
|
||||
async countByCommentId(commentId: string) {
|
||||
const results = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(commentLikes)
|
||||
.where(eq(commentLikes.commentId, commentId));
|
||||
return Number(results[0]?.count || 0);
|
||||
}
|
||||
|
||||
async isLikedByUser(commentId: string, userId: string) {
|
||||
const results = await this.databaseService.db
|
||||
.select()
|
||||
.from(commentLikes)
|
||||
.where(
|
||||
and(eq(commentLikes.commentId, commentId), eq(commentLikes.userId, userId)),
|
||||
);
|
||||
return !!results[0];
|
||||
}
|
||||
}
|
||||
75
backend/src/comments/repositories/comments.repository.ts
Normal file
75
backend/src/comments/repositories/comments.repository.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { comments, users } from "../../database/schemas";
|
||||
import type { NewCommentInDb } from "../../database/schemas/comments";
|
||||
|
||||
@Injectable()
|
||||
export class CommentsRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async create(data: NewCommentInDb) {
|
||||
const results = await this.databaseService.db
|
||||
.insert(comments)
|
||||
.values(data)
|
||||
.returning();
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async findAllByContentId(contentId: string) {
|
||||
return this.databaseService.db
|
||||
.select({
|
||||
id: comments.id,
|
||||
text: comments.text,
|
||||
parentId: comments.parentId,
|
||||
createdAt: comments.createdAt,
|
||||
updatedAt: comments.updatedAt,
|
||||
user: {
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(users, eq(comments.userId, users.uuid))
|
||||
.where(and(eq(comments.contentId, contentId), isNull(comments.deletedAt)))
|
||||
.orderBy(desc(comments.createdAt));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const results = await this.databaseService.db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async findOneEnriched(id: string) {
|
||||
const results = await this.databaseService.db
|
||||
.select({
|
||||
id: comments.id,
|
||||
text: comments.text,
|
||||
parentId: comments.parentId,
|
||||
createdAt: comments.createdAt,
|
||||
updatedAt: comments.updatedAt,
|
||||
user: {
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(users, eq(comments.userId, users.uuid))
|
||||
.where(and(eq(comments.id, id), isNull(comments.deletedAt)));
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await this.databaseService.db
|
||||
.update(comments)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(comments.id, id));
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export interface AuthenticatedRequest extends Request {
|
||||
user: {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||
import type { Cache } from "cache-manager";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||
private readonly logger = new Logger("CrawlerDetection");
|
||||
|
||||
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
|
||||
|
||||
private readonly SUSPICIOUS_PATTERNS = [
|
||||
/\.env/,
|
||||
/wp-admin/,
|
||||
@@ -24,7 +28,7 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||
/db\./,
|
||||
/backup\./,
|
||||
/cgi-bin/,
|
||||
/\.well-known\/security\.txt/, // Bien que légitime, souvent scanné
|
||||
/\.well-known\/security\.txt/,
|
||||
];
|
||||
|
||||
private readonly BOT_USER_AGENTS = [
|
||||
@@ -40,11 +44,21 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||
/masscan/i,
|
||||
];
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
async use(req: Request, res: Response, next: NextFunction) {
|
||||
const { method, url, ip } = req;
|
||||
const userAgent = req.get("user-agent") || "unknown";
|
||||
|
||||
res.on("finish", () => {
|
||||
// Vérifier si l'IP est bannie
|
||||
const isBanned = await this.cacheManager.get(`banned_ip:${ip}`);
|
||||
if (isBanned) {
|
||||
this.logger.warn(`Banned IP attempt: ${ip} -> ${method} ${url}`);
|
||||
res.status(403).json({
|
||||
message: "Access denied: Your IP has been temporarily banned.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.on("finish", async () => {
|
||||
if (res.statusCode === 404) {
|
||||
const isSuspiciousPath = this.SUSPICIOUS_PATTERNS.some((pattern) =>
|
||||
pattern.test(url),
|
||||
@@ -57,7 +71,9 @@ export class CrawlerDetectionMiddleware implements NestMiddleware {
|
||||
this.logger.warn(
|
||||
`Potential crawler detected: [${ip}] ${method} ${url} - User-Agent: ${userAgent}`,
|
||||
);
|
||||
// Ici, on pourrait ajouter une logique pour bannir l'IP temporairement via Redis
|
||||
|
||||
// Bannir l'IP pour 24h via Redis
|
||||
await this.cacheManager.set(`banned_ip:${ip}`, true, 86400000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ export const envSchema = z.object({
|
||||
// Media Limits
|
||||
MAX_IMAGE_SIZE_KB: z.coerce.number().default(512),
|
||||
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>;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { MediaModule } from "../media/media.module";
|
||||
import { RealtimeModule } from "../realtime/realtime.module";
|
||||
import { S3Module } from "../s3/s3.module";
|
||||
import { ContentsController } from "./contents.controller";
|
||||
import { ContentsService } from "./contents.service";
|
||||
import { ContentsRepository } from "./repositories/contents.repository";
|
||||
|
||||
@Module({
|
||||
imports: [S3Module, AuthModule, MediaModule],
|
||||
imports: [S3Module, AuthModule, MediaModule, RealtimeModule],
|
||||
controllers: [ContentsController],
|
||||
providers: [ContentsService, ContentsRepository],
|
||||
exports: [ContentsRepository],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { ContentsService } from "./contents.service";
|
||||
import { ContentsRepository } from "./repositories/contents.repository";
|
||||
@@ -49,6 +50,10 @@ describe("ContentsService", () => {
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEventsGateway = {
|
||||
sendToUser: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -60,6 +65,7 @@ describe("ContentsService", () => {
|
||||
{ provide: MediaService, useValue: mockMediaService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from "../common/interfaces/media.interface";
|
||||
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { CreateContentDto } from "./dto/create-content.dto";
|
||||
import { UploadContentDto } from "./dto/upload-content.dto";
|
||||
@@ -29,6 +30,7 @@ export class ContentsService {
|
||||
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
private readonly eventsGateway: EventsGateway,
|
||||
) {}
|
||||
|
||||
private async clearContentsCache() {
|
||||
@@ -48,6 +50,11 @@ export class ContentsService {
|
||||
data: UploadContentDto,
|
||||
) {
|
||||
this.logger.log(`Uploading and processing file for user ${userId}`);
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "starting",
|
||||
progress: 0,
|
||||
});
|
||||
|
||||
// 0. Validation du format et de la taille
|
||||
const allowedMimeTypes = [
|
||||
"image/png",
|
||||
@@ -55,60 +62,117 @@ export class ContentsService {
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"video/webm",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
];
|
||||
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "error",
|
||||
message: "Format de fichier non supporté",
|
||||
});
|
||||
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 maxSizeKb = isGif
|
||||
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
|
||||
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
||||
// Autodétermination du type si non fourni ou pour valider
|
||||
let contentType: "meme" | "gif" | "video" = "meme";
|
||||
if (file.mimetype === "image/gif") {
|
||||
contentType = "gif";
|
||||
} else if (file.mimetype.startsWith("video/")) {
|
||||
contentType = "video";
|
||||
}
|
||||
|
||||
const isGif = contentType === "gif";
|
||||
const isVideo = contentType === "video";
|
||||
let maxSizeKb: number;
|
||||
|
||||
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) {
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "error",
|
||||
message: "Fichier trop volumineux",
|
||||
});
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Scan Antivirus
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "scanning",
|
||||
progress: 20,
|
||||
});
|
||||
const scanResult = await this.mediaService.scanFile(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
);
|
||||
if (scanResult.isInfected) {
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "error",
|
||||
message: "Fichier infecté",
|
||||
});
|
||||
throw new BadRequestException(
|
||||
`Le fichier est infecté par ${scanResult.virusName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Transcodage
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "processing",
|
||||
progress: 40,
|
||||
});
|
||||
let processed: MediaProcessingResult;
|
||||
if (file.mimetype.startsWith("image/")) {
|
||||
// Image ou GIF -> WebP (format moderne, bien supporté)
|
||||
if (file.mimetype.startsWith("image/") && file.mimetype !== "image/gif") {
|
||||
// Image -> WebP (format moderne, bien supporté)
|
||||
processed = await this.mediaService.processImage(file.buffer, "webp");
|
||||
} else if (file.mimetype.startsWith("video/")) {
|
||||
// Vidéo -> WebM
|
||||
} else if (
|
||||
file.mimetype.startsWith("video/") ||
|
||||
file.mimetype === "image/gif"
|
||||
) {
|
||||
// Vidéo ou GIF -> WebM
|
||||
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
||||
} else {
|
||||
throw new BadRequestException("Format de fichier non supporté");
|
||||
}
|
||||
|
||||
// 3. Upload vers S3
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "uploading_s3",
|
||||
progress: 70,
|
||||
});
|
||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||
this.logger.log(`File uploaded successfully to S3: ${key}`);
|
||||
|
||||
// 4. Création en base de données
|
||||
return await this.create(userId, {
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "saving",
|
||||
progress: 90,
|
||||
});
|
||||
const content = await this.create(userId, {
|
||||
...data,
|
||||
type: contentType, // Utiliser le type autodéterminé
|
||||
storageKey: key,
|
||||
mimeType: processed.mimeType,
|
||||
fileSize: processed.size,
|
||||
});
|
||||
|
||||
this.eventsGateway.sendToUser(userId, "upload_progress", {
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
contentId: content.id,
|
||||
});
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
async findAll(options: {
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
export enum ContentType {
|
||||
MEME = "meme",
|
||||
GIF = "gif",
|
||||
VIDEO = "video",
|
||||
}
|
||||
|
||||
export class CreateContentDto {
|
||||
@IsEnum(ContentType)
|
||||
type!: "meme" | "gif";
|
||||
type!: "meme" | "gif" | "video";
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ContentType } from "./create-content.dto";
|
||||
|
||||
export class UploadContentDto {
|
||||
@IsEnum(ContentType)
|
||||
type!: "meme" | "gif";
|
||||
type!: "meme" | "gif" | "video";
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
21
backend/src/database/schemas/comment_likes.ts
Normal file
21
backend/src/database/schemas/comment_likes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { comments } from "./comments";
|
||||
import { users } from "./users";
|
||||
|
||||
export const commentLikes = pgTable(
|
||||
"comment_likes",
|
||||
{
|
||||
commentId: uuid("comment_id")
|
||||
.notNull()
|
||||
.references(() => comments.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.commentId, t.userId] }),
|
||||
}),
|
||||
);
|
||||
35
backend/src/database/schemas/comments.ts
Normal file
35
backend/src/database/schemas/comments.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { contents } from "./content";
|
||||
import { users } from "./users";
|
||||
|
||||
export const comments = pgTable(
|
||||
"comments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
contentId: uuid("content_id")
|
||||
.notNull()
|
||||
.references(() => contents.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||
parentId: uuid("parent_id").references(() => comments.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
text: text("text").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
contentIdIdx: index("comments_content_id_idx").on(table.contentId),
|
||||
userIdIdx: index("comments_user_id_idx").on(table.userId),
|
||||
parentIdIdx: index("comments_parent_id_idx").on(table.parentId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type CommentInDb = typeof comments.$inferSelect;
|
||||
export type NewCommentInDb = typeof comments.$inferInsert;
|
||||
@@ -12,7 +12,7 @@ import { categories } from "./categories";
|
||||
import { tags } from "./tags";
|
||||
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(
|
||||
"contents",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export * from "./api_keys";
|
||||
export * from "./audit_logs";
|
||||
export * from "./categories";
|
||||
export * from "./comment_likes";
|
||||
export * from "./comments";
|
||||
export * from "./content";
|
||||
export * from "./favorites";
|
||||
export * from "./messages";
|
||||
export * from "./pgp";
|
||||
export * from "./rbac";
|
||||
export * from "./reports";
|
||||
|
||||
66
backend/src/database/schemas/messages.ts
Normal file
66
backend/src/database/schemas/messages.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
index,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./users";
|
||||
|
||||
export const conversations = pgTable("conversations", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export const conversationParticipants = pgTable(
|
||||
"conversation_participants",
|
||||
{
|
||||
conversationId: uuid("conversation_id")
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.conversationId, t.userId] }),
|
||||
}),
|
||||
);
|
||||
|
||||
export const messages = pgTable(
|
||||
"messages",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
conversationId: uuid("conversation_id")
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||
senderId: uuid("sender_id")
|
||||
.notNull()
|
||||
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||
text: text("text").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
readAt: timestamp("read_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
conversationIdIdx: index("messages_conversation_id_idx").on(
|
||||
table.conversationId,
|
||||
),
|
||||
senderIdIdx: index("messages_sender_id_idx").on(table.senderId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type ConversationInDb = typeof conversations.$inferSelect;
|
||||
export type NewConversationInDb = typeof conversations.$inferInsert;
|
||||
export type MessageInDb = typeof messages.$inferSelect;
|
||||
export type NewMessageInDb = typeof messages.$inferInsert;
|
||||
@@ -41,7 +41,9 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
|
||||
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
|
||||
const originalGetSQL = column.getSQL.bind(column);
|
||||
column.getSQL = () =>
|
||||
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column);
|
||||
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
|
||||
column,
|
||||
);
|
||||
return column;
|
||||
}
|
||||
|
||||
@@ -59,5 +61,7 @@ export function pgpSymDecrypt(
|
||||
column: AnyPgColumn,
|
||||
key: string | SQL,
|
||||
): SQL<string> {
|
||||
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>;
|
||||
return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
|
||||
column,
|
||||
) as SQL<string>;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export const users = pgTable(
|
||||
// Sécurité
|
||||
twoFactorSecret: pgpEncrypted("two_factor_secret"),
|
||||
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
|
||||
showOnlineStatus: boolean("show_online_status").notNull().default(true),
|
||||
showReadReceipts: boolean("show_read_receipts").notNull().default(true),
|
||||
|
||||
// RGPD & Conformité
|
||||
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("MediaService", () => {
|
||||
toFormat: jest.fn().mockReturnThis(),
|
||||
videoCodec: jest.fn().mockReturnThis(),
|
||||
audioCodec: jest.fn().mockReturnThis(),
|
||||
outputOptions: jest.fn().mockReturnThis(),
|
||||
addOutputOptions: jest.fn().mockReturnThis(),
|
||||
on: jest.fn().mockImplementation(function (event, cb) {
|
||||
if (event === "end") setTimeout(cb, 0);
|
||||
return this;
|
||||
|
||||
@@ -12,7 +12,7 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
||||
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
||||
|
||||
canHandle(mimeType: string): boolean {
|
||||
return mimeType.startsWith("video/");
|
||||
return mimeType.startsWith("video/") || mimeType === "image/gif";
|
||||
}
|
||||
|
||||
async process(
|
||||
@@ -37,13 +37,13 @@ export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
||||
.toFormat("webm")
|
||||
.videoCodec("libvpx-vp9")
|
||||
.audioCodec("libopus")
|
||||
.outputOptions("-crf 30", "-b:v 0");
|
||||
.addOutputOptions("-crf", "30", "-b:v", "0");
|
||||
} else {
|
||||
command = command
|
||||
.toFormat("mp4")
|
||||
.videoCodec("libaom-av1")
|
||||
.audioCodec("libopus")
|
||||
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
|
||||
.addOutputOptions("-crf", "34", "-b:v", "0", "-strict", "experimental");
|
||||
}
|
||||
|
||||
command
|
||||
|
||||
11
backend/src/messages/dto/create-message.dto.ts
Normal file
11
backend/src/messages/dto/create-message.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateMessageDto {
|
||||
@IsUUID()
|
||||
recipientId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
text!: string;
|
||||
}
|
||||
53
backend/src/messages/messages.controller.ts
Normal file
53
backend/src/messages/messages.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { CreateMessageDto } from "./dto/create-message.dto";
|
||||
import { MessagesService } from "./messages.service";
|
||||
|
||||
@Controller("messages")
|
||||
@UseGuards(AuthGuard)
|
||||
export class MessagesController {
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
@Get("conversations")
|
||||
getConversations(@Req() req: AuthenticatedRequest) {
|
||||
return this.messagesService.getConversations(req.user.sub);
|
||||
}
|
||||
|
||||
@Get("unread-count")
|
||||
getUnreadCount(@Req() req: AuthenticatedRequest) {
|
||||
return this.messagesService.getUnreadCount(req.user.sub);
|
||||
}
|
||||
|
||||
@Get("conversations/with/:userId")
|
||||
getConversationWithUser(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("userId") targetUserId: string,
|
||||
) {
|
||||
return this.messagesService.getConversationWithUser(
|
||||
req.user.sub,
|
||||
targetUserId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("conversations/:id")
|
||||
getMessages(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("id") conversationId: string,
|
||||
) {
|
||||
return this.messagesService.getMessages(req.user.sub, conversationId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
sendMessage(@Req() req: AuthenticatedRequest, @Body() dto: CreateMessageDto) {
|
||||
return this.messagesService.sendMessage(req.user.sub, dto);
|
||||
}
|
||||
}
|
||||
15
backend/src/messages/messages.module.ts
Normal file
15
backend/src/messages/messages.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { RealtimeModule } from "../realtime/realtime.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { MessagesController } from "./messages.controller";
|
||||
import { MessagesService } from "./messages.service";
|
||||
import { MessagesRepository } from "./repositories/messages.repository";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, RealtimeModule, forwardRef(() => UsersModule)],
|
||||
controllers: [MessagesController],
|
||||
providers: [MessagesService, MessagesRepository],
|
||||
exports: [MessagesService],
|
||||
})
|
||||
export class MessagesModule {}
|
||||
105
backend/src/messages/messages.service.spec.ts
Normal file
105
backend/src/messages/messages.service.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { MessagesService } from "./messages.service";
|
||||
import { MessagesRepository } from "./repositories/messages.repository";
|
||||
|
||||
describe("MessagesService", () => {
|
||||
let service: MessagesService;
|
||||
let _repository: MessagesRepository;
|
||||
let _eventsGateway: EventsGateway;
|
||||
|
||||
const mockMessagesRepository = {
|
||||
findConversationBetweenUsers: jest.fn(),
|
||||
createConversation: jest.fn(),
|
||||
addParticipant: jest.fn(),
|
||||
createMessage: jest.fn(),
|
||||
findAllConversations: jest.fn(),
|
||||
isParticipant: jest.fn(),
|
||||
getParticipants: jest.fn(),
|
||||
findMessagesByConversationId: jest.fn(),
|
||||
markAsRead: jest.fn(),
|
||||
countUnreadMessages: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEventsGateway = {
|
||||
sendToUser: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUsersService = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MessagesService,
|
||||
{ provide: MessagesRepository, useValue: mockMessagesRepository },
|
||||
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MessagesService>(MessagesService);
|
||||
_repository = module.get<MessagesRepository>(MessagesRepository);
|
||||
_eventsGateway = module.get<EventsGateway>(EventsGateway);
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("should send message to existing conversation", async () => {
|
||||
const senderId = "s1";
|
||||
const dto = { recipientId: "r1", text: "hello" };
|
||||
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue({
|
||||
id: "conv1",
|
||||
});
|
||||
mockMessagesRepository.createMessage.mockResolvedValue({
|
||||
id: "m1",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
const result = await service.sendMessage(senderId, dto);
|
||||
|
||||
expect(result.id).toBe("m1");
|
||||
expect(mockEventsGateway.sendToUser).toHaveBeenCalledWith(
|
||||
"r1",
|
||||
"new_message",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should create new conversation if not exists", async () => {
|
||||
const senderId = "s1";
|
||||
const dto = { recipientId: "r1", text: "hello" };
|
||||
mockMessagesRepository.findConversationBetweenUsers.mockResolvedValue(null);
|
||||
mockMessagesRepository.createConversation.mockResolvedValue({
|
||||
id: "new_conv",
|
||||
});
|
||||
mockMessagesRepository.createMessage.mockResolvedValue({ id: "m1" });
|
||||
|
||||
await service.sendMessage(senderId, dto);
|
||||
|
||||
expect(mockMessagesRepository.createConversation).toHaveBeenCalled();
|
||||
expect(mockMessagesRepository.addParticipant).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMessages", () => {
|
||||
it("should return messages if user is participant", async () => {
|
||||
mockMessagesRepository.isParticipant.mockResolvedValue(true);
|
||||
mockMessagesRepository.findMessagesByConversationId.mockResolvedValue([
|
||||
{ id: "m1" },
|
||||
]);
|
||||
|
||||
const result = await service.getMessages("u1", "conv1");
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException if user is not participant", async () => {
|
||||
mockMessagesRepository.isParticipant.mockResolvedValue(false);
|
||||
await expect(service.getMessages("u1", "conv1")).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
125
backend/src/messages/messages.service.ts
Normal file
125
backend/src/messages/messages.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import type { CreateMessageDto } from "./dto/create-message.dto";
|
||||
import { MessagesRepository } from "./repositories/messages.repository";
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
private readonly messagesRepository: MessagesRepository,
|
||||
private readonly eventsGateway: EventsGateway,
|
||||
@Inject(forwardRef(() => UsersService))
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async sendMessage(senderId: string, dto: CreateMessageDto) {
|
||||
let conversation = await this.messagesRepository.findConversationBetweenUsers(
|
||||
senderId,
|
||||
dto.recipientId,
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
const newConv = await this.messagesRepository.createConversation();
|
||||
await this.messagesRepository.addParticipant(newConv.id, senderId);
|
||||
await this.messagesRepository.addParticipant(newConv.id, dto.recipientId);
|
||||
conversation = newConv;
|
||||
}
|
||||
|
||||
const message = await this.messagesRepository.createMessage({
|
||||
conversationId: conversation.id,
|
||||
senderId,
|
||||
text: dto.text,
|
||||
});
|
||||
|
||||
// Notify recipient via WebSocket
|
||||
this.eventsGateway.sendToUser(dto.recipientId, "new_message", {
|
||||
conversationId: conversation.id,
|
||||
message,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async getConversations(userId: string) {
|
||||
return this.messagesRepository.findAllConversations(userId);
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string) {
|
||||
return this.messagesRepository.countUnreadMessages(userId);
|
||||
}
|
||||
|
||||
async getConversationWithUser(userId: string, targetUserId: string) {
|
||||
return this.messagesRepository.findConversationBetweenUsers(
|
||||
userId,
|
||||
targetUserId,
|
||||
);
|
||||
}
|
||||
|
||||
async getMessages(userId: string, conversationId: string) {
|
||||
const isParticipant = await this.messagesRepository.isParticipant(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!isParticipant) {
|
||||
throw new ForbiddenException("You are not part of this conversation");
|
||||
}
|
||||
|
||||
// Récupérer les préférences de l'utilisateur actuel
|
||||
const user = await this.usersService.findOne(userId);
|
||||
|
||||
// Marquer comme lus seulement si l'utilisateur l'autorise
|
||||
if (user?.showReadReceipts) {
|
||||
await this.messagesRepository.markAsRead(conversationId, userId);
|
||||
|
||||
// Notifier l'expéditeur que les messages ont été lus
|
||||
const participants =
|
||||
await this.messagesRepository.getParticipants(conversationId);
|
||||
const otherParticipant = participants.find((p) => p.userId !== userId);
|
||||
if (otherParticipant) {
|
||||
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
|
||||
conversationId,
|
||||
readerId: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.messagesRepository.findMessagesByConversationId(conversationId);
|
||||
}
|
||||
|
||||
async markAsRead(userId: string, conversationId: string) {
|
||||
const isParticipant = await this.messagesRepository.isParticipant(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!isParticipant) {
|
||||
throw new ForbiddenException("You are not part of this conversation");
|
||||
}
|
||||
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user?.showReadReceipts) return;
|
||||
|
||||
const result = await this.messagesRepository.markAsRead(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
|
||||
// Notifier l'autre participant
|
||||
const participants =
|
||||
await this.messagesRepository.getParticipants(conversationId);
|
||||
const otherParticipant = participants.find((p) => p.userId !== userId);
|
||||
if (otherParticipant) {
|
||||
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
|
||||
conversationId,
|
||||
readerId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
167
backend/src/messages/repositories/messages.repository.ts
Normal file
167
backend/src/messages/repositories/messages.repository.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import {
|
||||
conversationParticipants,
|
||||
conversations,
|
||||
messages,
|
||||
users,
|
||||
} from "../../database/schemas";
|
||||
|
||||
@Injectable()
|
||||
export class MessagesRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async findConversationBetweenUsers(userId1: string, userId2: string) {
|
||||
const results = await this.databaseService.db
|
||||
.select({ id: conversations.id })
|
||||
.from(conversations)
|
||||
.innerJoin(
|
||||
conversationParticipants,
|
||||
eq(conversations.id, conversationParticipants.conversationId),
|
||||
)
|
||||
.where(inArray(conversationParticipants.userId, [userId1, userId2]))
|
||||
.groupBy(conversations.id)
|
||||
.having(sql`count(${conversations.id}) = 2`);
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async createConversation() {
|
||||
const [conv] = await this.databaseService.db
|
||||
.insert(conversations)
|
||||
.values({})
|
||||
.returning();
|
||||
return conv;
|
||||
}
|
||||
|
||||
async addParticipant(conversationId: string, userId: string) {
|
||||
await this.databaseService.db
|
||||
.insert(conversationParticipants)
|
||||
.values({ conversationId, userId });
|
||||
}
|
||||
|
||||
async createMessage(data: {
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
text: string;
|
||||
}) {
|
||||
const [msg] = await this.databaseService.db
|
||||
.insert(messages)
|
||||
.values(data)
|
||||
.returning();
|
||||
|
||||
// Update conversation updatedAt
|
||||
await this.databaseService.db
|
||||
.update(conversations)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(conversations.id, data.conversationId));
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
async findAllConversations(userId: string) {
|
||||
// Sous-requête pour trouver les IDs des conversations de l'utilisateur
|
||||
const userConvs = this.databaseService.db
|
||||
.select({ id: conversationParticipants.conversationId })
|
||||
.from(conversationParticipants)
|
||||
.where(eq(conversationParticipants.userId, userId));
|
||||
|
||||
return this.databaseService.db
|
||||
.select({
|
||||
id: conversations.id,
|
||||
updatedAt: conversations.updatedAt,
|
||||
lastMessage: {
|
||||
text: messages.text,
|
||||
createdAt: messages.createdAt,
|
||||
},
|
||||
recipient: {
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
},
|
||||
})
|
||||
.from(conversations)
|
||||
.innerJoin(
|
||||
conversationParticipants,
|
||||
eq(conversations.id, conversationParticipants.conversationId),
|
||||
)
|
||||
.innerJoin(users, eq(conversationParticipants.userId, users.uuid))
|
||||
.leftJoin(messages, eq(conversations.id, messages.conversationId))
|
||||
.where(
|
||||
and(
|
||||
inArray(conversations.id, userConvs),
|
||||
eq(conversationParticipants.userId, users.uuid),
|
||||
sql`${users.uuid} != ${userId}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(conversations.updatedAt));
|
||||
}
|
||||
|
||||
async findMessagesByConversationId(conversationId: string, limit = 50) {
|
||||
return this.databaseService.db
|
||||
.select({
|
||||
id: messages.id,
|
||||
text: messages.text,
|
||||
createdAt: messages.createdAt,
|
||||
senderId: messages.senderId,
|
||||
readAt: messages.readAt,
|
||||
})
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, conversationId))
|
||||
.orderBy(desc(messages.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async isParticipant(conversationId: string, userId: string) {
|
||||
const [participant] = await this.databaseService.db
|
||||
.select()
|
||||
.from(conversationParticipants)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipants.conversationId, conversationId),
|
||||
eq(conversationParticipants.userId, userId),
|
||||
),
|
||||
);
|
||||
return !!participant;
|
||||
}
|
||||
|
||||
async getParticipants(conversationId: string) {
|
||||
return this.databaseService.db
|
||||
.select({ userId: conversationParticipants.userId })
|
||||
.from(conversationParticipants)
|
||||
.where(eq(conversationParticipants.conversationId, conversationId));
|
||||
}
|
||||
|
||||
async markAsRead(conversationId: string, userId: string) {
|
||||
await this.databaseService.db
|
||||
.update(messages)
|
||||
.set({ readAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(messages.conversationId, conversationId),
|
||||
sql`${messages.senderId} != ${userId}`,
|
||||
sql`${messages.readAt} IS NULL`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async countUnreadMessages(userId: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(messages)
|
||||
.innerJoin(
|
||||
conversationParticipants,
|
||||
eq(messages.conversationId, conversationParticipants.conversationId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipants.userId, userId),
|
||||
sql`${messages.senderId} != ${userId}`,
|
||||
sql`${messages.readAt} IS NULL`,
|
||||
),
|
||||
);
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
}
|
||||
60
backend/src/realtime/events.gateway.spec.ts
Normal file
60
backend/src/realtime/events.gateway.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { JwtService } from "../crypto/services/jwt.service";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { EventsGateway } from "./events.gateway";
|
||||
|
||||
describe("EventsGateway", () => {
|
||||
let gateway: EventsGateway;
|
||||
let _jwtService: JwtService;
|
||||
|
||||
const mockJwtService = {
|
||||
verifyJwt: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"),
|
||||
};
|
||||
|
||||
const mockUsersService = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EventsGateway,
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
gateway = module.get<EventsGateway>(EventsGateway);
|
||||
_jwtService = module.get<JwtService>(JwtService);
|
||||
gateway.server = {
|
||||
to: jest.fn().mockReturnThis(),
|
||||
emit: jest.fn(),
|
||||
} as any;
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(gateway).toBeDefined();
|
||||
});
|
||||
|
||||
describe("sendToUser", () => {
|
||||
it("should emit event to user room", () => {
|
||||
const userId = "user123";
|
||||
const event = "test_event";
|
||||
const data = { foo: "bar" };
|
||||
|
||||
gateway.sendToUser(userId, event, data);
|
||||
|
||||
expect(gateway.server.to).toHaveBeenCalledWith(`user:${userId}`);
|
||||
expect(gateway.server.to(`user:${userId}`).emit).toHaveBeenCalledWith(
|
||||
event,
|
||||
data,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
220
backend/src/realtime/events.gateway.ts
Normal file
220
backend/src/realtime/events.gateway.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { forwardRef, Inject, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from "@nestjs/websockets";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { getSessionOptions, SessionData } from "../auth/session.config";
|
||||
import { JwtService } from "../crypto/services/jwt.service";
|
||||
import { UsersService } from "../users/users.service";
|
||||
|
||||
@WebSocketGateway({
|
||||
transports: ["websocket"],
|
||||
cors: {
|
||||
origin: (
|
||||
origin: string,
|
||||
callback: (err: Error | null, allow?: boolean) => void,
|
||||
) => {
|
||||
// Autoriser si pas d'origine (ex: app mobile ou serveur à serveur)
|
||||
// ou si on est en développement local
|
||||
if (
|
||||
!origin ||
|
||||
origin.includes("localhost") ||
|
||||
origin.includes("127.0.0.1")
|
||||
) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// En production, on peut restreindre via une variable d'environnement
|
||||
const domainName = process.env.CORS_DOMAIN_NAME;
|
||||
if (!domainName || domainName === "*") {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedOrigins = domainName.split(",").map((o) => o.trim());
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
})
|
||||
export class EventsGateway
|
||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(EventsGateway.name);
|
||||
private readonly onlineUsers = new Map<string, Set<string>>(); // userId -> Set of socketIds
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(forwardRef(() => UsersService))
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
afterInit(_server: Server) {
|
||||
this.logger.log("WebSocket Gateway initialized");
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
// Simuler un objet Request/Response pour iron-session
|
||||
const req: any = {
|
||||
headers: client.handshake.headers,
|
||||
};
|
||||
const res: any = {
|
||||
setHeader: () => {},
|
||||
getHeader: () => {},
|
||||
};
|
||||
|
||||
const session = await getIronSession<SessionData>(
|
||||
req,
|
||||
res,
|
||||
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||
);
|
||||
|
||||
if (!session.accessToken) {
|
||||
this.logger.warn(`Client ${client.id} unauthorized connection`);
|
||||
// Permettre les connexions anonymes pour voir les commentaires en temps réel ?
|
||||
// Pour l'instant on déconnecte car le système actuel semble exiger l'auth
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await this.jwtService.verifyJwt(session.accessToken);
|
||||
if (!payload.sub) {
|
||||
throw new Error("Invalid token payload: missing sub");
|
||||
}
|
||||
|
||||
client.data.user = payload;
|
||||
|
||||
// Rejoindre une room personnelle pour les notifications
|
||||
client.join(`user:${payload.sub}`);
|
||||
|
||||
// Gérer le statut en ligne
|
||||
const userId = payload.sub as string;
|
||||
|
||||
if (!this.onlineUsers.has(userId)) {
|
||||
this.onlineUsers.set(userId, new Set());
|
||||
|
||||
// Vérifier les préférences de l'utilisateur
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (user?.showOnlineStatus) {
|
||||
this.broadcastStatus(userId, "online");
|
||||
}
|
||||
}
|
||||
this.onlineUsers.get(userId)?.add(client.id);
|
||||
|
||||
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Connection error for client ${client.id}: ${error}`);
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async handleDisconnect(client: Socket) {
|
||||
const userId = client.data.user?.sub;
|
||||
if (userId && this.onlineUsers.has(userId)) {
|
||||
const sockets = this.onlineUsers.get(userId);
|
||||
sockets?.delete(client.id);
|
||||
if (sockets?.size === 0) {
|
||||
this.onlineUsers.delete(userId);
|
||||
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (user?.showOnlineStatus) {
|
||||
this.broadcastStatus(userId, "offline");
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
broadcastStatus(userId: string, status: "online" | "offline") {
|
||||
this.server.emit("user_status", { userId, status });
|
||||
}
|
||||
|
||||
isUserOnline(userId: string): boolean {
|
||||
return this.onlineUsers.has(userId);
|
||||
}
|
||||
|
||||
@SubscribeMessage("join_content")
|
||||
handleJoinContent(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() contentId: string,
|
||||
) {
|
||||
client.join(`content:${contentId}`);
|
||||
this.logger.log(`Client ${client.id} joined content room: ${contentId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage("leave_content")
|
||||
handleLeaveContent(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() contentId: string,
|
||||
) {
|
||||
client.leave(`content:${contentId}`);
|
||||
this.logger.log(`Client ${client.id} left content room: ${contentId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage("typing")
|
||||
async handleTyping(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: { recipientId: string; isTyping: boolean },
|
||||
) {
|
||||
const userId = client.data.user?.sub;
|
||||
if (!userId) return;
|
||||
|
||||
// Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing"
|
||||
// ou si on considère que typing est une interaction directe qui outrepasse le statut.
|
||||
// Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat.
|
||||
// Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier.
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user?.showOnlineStatus) return;
|
||||
|
||||
this.server.to(`user:${data.recipientId}`).emit("user_typing", {
|
||||
userId,
|
||||
isTyping: data.isTyping,
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage("check_status")
|
||||
async handleCheckStatus(
|
||||
@ConnectedSocket() _client: Socket,
|
||||
@MessageBody() userId: string,
|
||||
) {
|
||||
const isOnline = this.onlineUsers.has(userId);
|
||||
if (!isOnline) return { userId, status: "offline" };
|
||||
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user?.showOnlineStatus) return { userId, status: "offline" };
|
||||
|
||||
return {
|
||||
userId,
|
||||
status: "online",
|
||||
};
|
||||
}
|
||||
|
||||
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
|
||||
sendToUser(userId: string, event: string, data: any) {
|
||||
this.server.to(`user:${userId}`).emit(event, data);
|
||||
}
|
||||
|
||||
sendToContent(contentId: string, event: string, data: any) {
|
||||
this.server.to(`content:${contentId}`).emit(event, data);
|
||||
}
|
||||
}
|
||||
12
backend/src/realtime/realtime.module.ts
Normal file
12
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { EventsGateway } from "./events.gateway";
|
||||
|
||||
@Module({
|
||||
imports: [CryptoModule, ConfigModule, forwardRef(() => UsersModule)],
|
||||
providers: [EventsGateway],
|
||||
exports: [EventsGateway],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@@ -22,4 +22,12 @@ export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
role?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showOnlineStatus?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showReadReceipts?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { and, eq, lte, sql } from "drizzle-orm";
|
||||
import { and, eq, ilike, lte, or, sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { contents, favorites, users } from "../../database/schemas";
|
||||
|
||||
@@ -47,6 +47,8 @@ export class UsersRepository {
|
||||
bio: users.bio,
|
||||
status: users.status,
|
||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||
showOnlineStatus: users.showOnlineStatus,
|
||||
showReadReceipts: users.showReadReceipts,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
@@ -97,6 +99,24 @@ export class UsersRepository {
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
return this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
})
|
||||
.from(users)
|
||||
.where(
|
||||
or(
|
||||
ilike(users.username, `%${query}%`),
|
||||
ilike(users.displayName, `%${query}%`),
|
||||
),
|
||||
)
|
||||
.limit(10);
|
||||
}
|
||||
|
||||
async findOne(uuid: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select()
|
||||
|
||||
@@ -54,6 +54,12 @@ export class UsersController {
|
||||
return this.usersService.findPublicProfile(username);
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
@UseGuards(AuthGuard)
|
||||
search(@Query("q") query: string) {
|
||||
return this.usersService.search(query);
|
||||
}
|
||||
|
||||
// Gestion de son propre compte
|
||||
@Get("me")
|
||||
@UseGuards(AuthGuard)
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { MediaModule } from "../media/media.module";
|
||||
import { RealtimeModule } from "../realtime/realtime.module";
|
||||
import { S3Module } from "../s3/s3.module";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
|
||||
imports: [
|
||||
forwardRef(() => AuthModule),
|
||||
MediaModule,
|
||||
S3Module,
|
||||
forwardRef(() => RealtimeModule),
|
||||
],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, UsersRepository],
|
||||
exports: [UsersService, UsersRepository],
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RbacService } from "../auth/rbac.service";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
import { UsersService } from "./users.service";
|
||||
@@ -49,6 +50,7 @@ describe("UsersService", () => {
|
||||
|
||||
const mockRbacService = {
|
||||
getUserRoles: jest.fn(),
|
||||
assignRoleToUser: jest.fn(),
|
||||
};
|
||||
|
||||
const mockMediaService = {
|
||||
@@ -65,6 +67,11 @@ describe("UsersService", () => {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEventsGateway = {
|
||||
isUserOnline: jest.fn(),
|
||||
broadcastStatus: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -77,6 +84,7 @@ describe("UsersService", () => {
|
||||
{ provide: MediaService, useValue: mockMediaService },
|
||||
{ provide: S3Service, useValue: mockS3Service },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: EventsGateway, useValue: mockEventsGateway },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -108,6 +116,7 @@ describe("UsersService", () => {
|
||||
describe("findOne", () => {
|
||||
it("should find a user", async () => {
|
||||
mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" });
|
||||
mockRbacService.getUserRoles.mockResolvedValue([]);
|
||||
const result = await service.findOne("uuid1");
|
||||
expect(result.uuid).toBe("uuid1");
|
||||
});
|
||||
@@ -139,6 +148,7 @@ describe("UsersService", () => {
|
||||
describe("findByEmailHash", () => {
|
||||
it("should call repository.findByEmailHash", async () => {
|
||||
mockUsersRepository.findByEmailHash.mockResolvedValue({ uuid: "u1" });
|
||||
mockRbacService.getUserRoles.mockResolvedValue([]);
|
||||
const result = await service.findByEmailHash("hash");
|
||||
expect(result.uuid).toBe("u1");
|
||||
expect(mockUsersRepository.findByEmailHash).toHaveBeenCalledWith("hash");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { RbacService } from "../auth/rbac.service";
|
||||
import type { IMediaService } from "../common/interfaces/media.interface";
|
||||
import type { IStorageService } from "../common/interfaces/storage.interface";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import { EventsGateway } from "../realtime/events.gateway";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
@@ -27,6 +28,8 @@ export class UsersService {
|
||||
private readonly rbacService: RbacService,
|
||||
@Inject(MediaService) private readonly mediaService: IMediaService,
|
||||
@Inject(S3Service) private readonly s3Service: IStorageService,
|
||||
@Inject(forwardRef(() => EventsGateway))
|
||||
private readonly eventsGateway: EventsGateway,
|
||||
) {}
|
||||
|
||||
private async clearUserCache(username?: string) {
|
||||
@@ -45,7 +48,19 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async findByEmailHash(emailHash: string) {
|
||||
return await this.usersRepository.findByEmailHash(emailHash);
|
||||
const user = await this.usersRepository.findByEmailHash(emailHash);
|
||||
if (!user) return null;
|
||||
|
||||
const roles = await this.rbacService.getUserRoles(user.uuid);
|
||||
return {
|
||||
...user,
|
||||
role: roles.includes("admin")
|
||||
? "admin"
|
||||
: roles.includes("moderator")
|
||||
? "moderator"
|
||||
: "user",
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
async findOneWithPrivateData(uuid: string) {
|
||||
@@ -94,8 +109,30 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
const users = await this.usersRepository.search(query);
|
||||
return users.map((user) => ({
|
||||
...user,
|
||||
avatarUrl: user.avatarUrl
|
||||
? this.s3Service.getPublicUrl(user.avatarUrl)
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(uuid: string) {
|
||||
return await this.usersRepository.findOne(uuid);
|
||||
const user = await this.usersRepository.findOne(uuid);
|
||||
if (!user) return null;
|
||||
|
||||
const roles = await this.rbacService.getUserRoles(user.uuid);
|
||||
return {
|
||||
...user,
|
||||
role: roles.includes("admin")
|
||||
? "admin"
|
||||
: roles.includes("moderator")
|
||||
? "moderator"
|
||||
: "user",
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
async update(uuid: string, data: UpdateUserDto) {
|
||||
@@ -103,6 +140,9 @@ export class UsersService {
|
||||
|
||||
const { role, ...userData } = data;
|
||||
|
||||
// On récupère l'utilisateur actuel avant mise à jour pour comparer les préférences
|
||||
const oldUser = await this.usersRepository.findOne(uuid);
|
||||
|
||||
const result = await this.usersRepository.update(uuid, userData);
|
||||
|
||||
if (role) {
|
||||
@@ -111,6 +151,21 @@ export class UsersService {
|
||||
|
||||
if (result[0]) {
|
||||
await this.clearUserCache(result[0].username);
|
||||
|
||||
// Gérer le changement de préférence de statut en ligne
|
||||
if (
|
||||
data.showOnlineStatus !== undefined &&
|
||||
data.showOnlineStatus !== oldUser?.showOnlineStatus
|
||||
) {
|
||||
const isOnline = this.eventsGateway.isUserOnline(uuid);
|
||||
if (isOnline) {
|
||||
if (data.showOnlineStatus) {
|
||||
this.eventsGateway.broadcastStatus(uuid, "online");
|
||||
} else {
|
||||
this.eventsGateway.broadcastStatus(uuid, "offline");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://memegoat.fr}
|
||||
NEXT_PUBLIC_CONTACT_EMAIL: ${MAIL_FROM:-noreply@memegoat.fr}
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ COPY documentation/package.json ./documentation/
|
||||
|
||||
# Montage du cache pnpm
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile --force
|
||||
|
||||
COPY . .
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile --force
|
||||
|
||||
# Build avec cache Next.js
|
||||
RUN --mount=type=cache,id=next-docs-cache,target=/usr/src/app/documentation/.next/cache \
|
||||
|
||||
@@ -1,139 +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 d’Administration 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 l’infrastructure (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 l’environnement](#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 d’accè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
|
||||
- [Choix de l'outil : Pourquoi PenPot ?](#choix-de-loutil--pourquoi-penpot-)
|
||||
- [Workflow de Design](#workflow-de-design)
|
||||
|
||||
- 4.4 [Analyse et Conception](#44-analyse-et-conception)
|
||||
- [Analyse des besoins et Personas](#analyse-des-besoins-et-personas)
|
||||
- [User Stories](#user-stories)
|
||||
- [Diagramme de Cas d'Utilisation (Use Case)](#diagramme-de-cas-dutilisation-use-case)
|
||||
- [Diagramme de Séquence (Flux d'Upload)](#diagramme-de-séquence-flux-dupload)
|
||||
|
||||
- 4.5 [Frontend](#45-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)
|
||||
|
||||
# 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.
|
||||
@@ -150,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.
|
||||
|
||||
| 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 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 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 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 5** | Créer une base de données | Modélisation et implémentation sous **PostgreSQL** via **Drizzle ORM**, incluant le chiffrement natif PGP. |
|
||||
| **CP 6** | Développer les composants d’accès aux données | Implémentation de services de données sous NestJS avec un typage strict (TypeScript) et validation via Zod. |
|
||||
| **CP 7** | Développer la partie back-end d’une application web ou mobile | Architecture modulaire **NestJS** intégrant JWT, RBAC et services métier complexes. |
|
||||
| **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 9** | Concevoir une application | Élaboration de diagrammes UML et choix d'une architecture monorepo pour la cohérence globale. |
|
||||
| **CP 10** | Collaborer à la gestion d’un projet informatique et à l’organisation de l’environnement de développement | Utilisation de Git (GitFlow), **Docker Compose** et gestion des tâches en méthode Agile. |
|
||||
| **CP 11** | Préparer le déploiement de l’application | Configuration de conteneurs Docker pour l'orchestration des services (API, DB, Redis, MinIO). |
|
||||
| **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. |
|
||||
| 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é. 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é. 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. 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. 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. Voir [B.2 Modélisation](#b2---modélisation--base-de-données-drizzle-orm-postgresql). |
|
||||
| **CP 6** | Développer les composants d’accè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 d’une 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. 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. Voir [4.4 Analyse](#44-analyse-et-conception). |
|
||||
| **CP 10** | Collaborer à la gestion d’un projet informatique et à l’organisation de l’environnement 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 l’application | 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. Voir [B.6 Qualité et Tests](#b6---qualité-et-tests). |
|
||||
|
||||
# 3. Cahier des charges
|
||||
|
||||
@@ -256,13 +120,22 @@ Le choix s'est porté sur **Ubuntu Sans** pour sa lisibilité exceptionnelle et
|
||||
### 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.
|
||||
|
||||
## 3.4 Spécifications de l’infrastructure (Docker, PostgreSQL, Redis, Minio)
|
||||
## 3.4 Spécifications de l’infrastructure
|
||||
|
||||
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).
|
||||
- **PostgreSQL** : Stockage relationnel avec extension `pgcrypto` pour PGP.
|
||||
- **Redis** : Cache de performance et gestion des sessions.
|
||||
- **MinIO** : Stockage d'objets compatible S3 pour les médias.
|
||||
- **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 17** : Stockage relationnel avec extension `pgcrypto` pour le chiffrement PGP.
|
||||
- **Redis 7** : Utilisé pour la mise en cache des requêtes API et la gestion des sessions à haute performance.
|
||||
- **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
|
||||
|
||||
@@ -294,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.
|
||||
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**.
|
||||
|
||||
@@ -716,65 +622,38 @@ sequenceDiagram
|
||||
|
||||
## 4.5 Frontend
|
||||
|
||||
### F.1 - Stack technique (Next.js 16, React 19, Tailwind CSS 4)
|
||||
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)
|
||||
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.
|
||||
- **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 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
|
||||
|
||||
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. 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)**.
|
||||
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**.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### F.3 - Interface dynamique et UX
|
||||
|
||||
#### 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**. 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.
|
||||
|
||||
#### 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.
|
||||
L'expérience utilisateur est au cœur du développement :
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### F.4 - SEO et Métadonnées avec Next.js
|
||||
|
||||
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.
|
||||
|
||||
#### 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.
|
||||
Memegoat est optimisé pour les moteurs de recherche :
|
||||
- **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".
|
||||
|
||||
### 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.
|
||||
|
||||
#### 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
|
||||
## 4.6 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.
|
||||
|
||||
@@ -792,7 +671,7 @@ En façade, nous utilisons **Caddy** comme serveur web et reverse proxy. Contrai
|
||||
### 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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -818,9 +697,10 @@ L'application tient à jour un registre des traitements limitant la collecte aux
|
||||
|
||||
### Droits des personnes
|
||||
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`.
|
||||
- **Droit d'opposition et de rectification** : Interface de gestion de compte permettant la mise à jour ou la suppression des informations personnelles à tout moment.
|
||||
- **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)
|
||||
- **Minimisation des données** : Seules les informations essentielles sont conservées.
|
||||
|
||||
@@ -14,13 +14,13 @@ COPY documentation/package.json ./documentation/
|
||||
|
||||
# Montage du cache pnpm
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile --force
|
||||
|
||||
COPY . .
|
||||
|
||||
# Deuxième passe avec cache pour les scripts/liens
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile --force
|
||||
|
||||
# Build avec cache Next.js
|
||||
RUN --mount=type=cache,id=next-cache,target=/usr/src/app/frontend/.next/cache \
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr";
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.memegoat.fr";
|
||||
|
||||
const getHostname = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
@@ -7,11 +18,11 @@ const nextConfig: NextConfig = {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "memegoat.fr",
|
||||
hostname: getHostname(appUrl),
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.memegoat.fr",
|
||||
hostname: getHostname(apiUrl),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/frontend",
|
||||
"version": "1.4.1",
|
||||
"version": "1.9.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -54,6 +54,7 @@
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"recharts": "2.15.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
@@ -24,20 +24,29 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email({ message: "Email invalide" }),
|
||||
password: z
|
||||
.string()
|
||||
.min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
|
||||
.min(8, { message: "Le mot de passe doit faire au moins 8 caractères" }),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const { login, verify2fa } = useAuth();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [show2fa, setShow2fa] = React.useState(false);
|
||||
const [userId, setUserId] = React.useState<string | null>(null);
|
||||
const [otpValue, setOtpValue] = React.useState("");
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
@@ -50,7 +59,11 @@ export default function LoginPage() {
|
||||
async function onSubmit(values: LoginFormValues) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(values.email, values.password);
|
||||
const res = await login(values.email, values.password);
|
||||
if (res.userId && res.message === "Please provide 2FA token") {
|
||||
setUserId(res.userId);
|
||||
setShow2fa(true);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Error is handled in useAuth via toast
|
||||
} finally {
|
||||
@@ -58,6 +71,20 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onOtpSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!userId || otpValue.length !== 6) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await verify2fa(userId, otpValue);
|
||||
} catch (_error) {
|
||||
// Error handled in useAuth
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
@@ -70,45 +97,89 @@ export default function LoginPage() {
|
||||
</Link>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Connexion</CardTitle>
|
||||
<CardTitle className="text-2xl">
|
||||
{show2fa ? "Double Authentification" : "Connexion"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Entrez vos identifiants pour accéder à votre compte MemeGoat.
|
||||
{show2fa
|
||||
? "Entrez le code à 6 chiffres de votre application d'authentification."
|
||||
: "Entrez vos identifiants pour accéder à votre compte MemeGoat."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="goat@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mot de passe</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Connexion en cours..." : "Se connecter"}
|
||||
{show2fa ? (
|
||||
<form
|
||||
onSubmit={onOtpSubmit}
|
||||
className="space-y-6 flex flex-col items-center"
|
||||
>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpValue}
|
||||
onChange={(value) => setOtpValue(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || otpValue.length !== 6}
|
||||
>
|
||||
{loading ? "Vérification..." : "Vérifier le code"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setShow2fa(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Retour
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="goat@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mot de passe</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Connexion en cours..." : "Se connecter"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col space-y-2">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
|
||||
@@ -29,11 +29,27 @@ import { useAuth } from "@/providers/auth-provider";
|
||||
const registerSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, { message: "Le pseudo doit faire au moins 3 caractères" }),
|
||||
.min(3, { message: "Le pseudo doit faire au moins 3 caractères" })
|
||||
.regex(/^[a-z0-9_]+$/, {
|
||||
message:
|
||||
"Le pseudo ne doit contenir que des minuscules, chiffres et underscores",
|
||||
}),
|
||||
email: z.string().email({ message: "Email invalide" }),
|
||||
password: z
|
||||
.string()
|
||||
.min(6, { message: "Le mot de passe doit faire au moins 6 caractères" }),
|
||||
.min(8, { message: "Le mot de passe doit faire au moins 8 caractères" })
|
||||
.regex(/[A-Z]/, {
|
||||
message: "Le mot de passe doit contenir au moins une majuscule",
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: "Le mot de passe doit contenir au moins une minuscule",
|
||||
})
|
||||
.regex(/[0-9]/, {
|
||||
message: "Le mot de passe doit contenir au moins un chiffre",
|
||||
})
|
||||
.regex(/[^A-Za-z0-9]/, {
|
||||
message: "Le mot de passe doit contenir au moins un caractère spécial",
|
||||
}),
|
||||
displayName: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -84,12 +100,25 @@ export default function RegisterPage() {
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="displayName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Le Roi des Chèvres" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pseudo</FormLabel>
|
||||
<FormLabel>Pseudo (minuscule)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="supergoat" {...field} />
|
||||
</FormControl>
|
||||
@@ -110,19 +139,6 @@ export default function RegisterPage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="displayName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom d'affichage (Optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Le Roi des Chèvres" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Content } from "@/types/content";
|
||||
|
||||
@@ -46,7 +45,6 @@ export default function MemeModal({
|
||||
</div>
|
||||
) : content ? (
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<ContentCard content={content} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, FileText, LayoutGrid, Users } from "lucide-react";
|
||||
import { AlertCircle, FileText, Flag, LayoutGrid, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -54,6 +54,13 @@ export default function AdminDashboardPage() {
|
||||
href: "/admin/categories",
|
||||
color: "text-purple-500",
|
||||
},
|
||||
{
|
||||
title: "Signalements",
|
||||
value: "Voir",
|
||||
icon: Flag,
|
||||
href: "/admin/reports",
|
||||
color: "text-red-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
204
frontend/src/app/(dashboard)/admin/reports/page.tsx
Normal file
204
frontend/src/app/(dashboard)/admin/reports/page.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
MoreHorizontal,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { adminService } from "@/services/admin.service";
|
||||
import { type Report, ReportStatus } from "@/services/report.service";
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminService.getReports();
|
||||
setReports(data);
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors du chargement des signalements.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
}, [fetchReports]);
|
||||
|
||||
const handleUpdateStatus = async (reportId: string, status: ReportStatus) => {
|
||||
try {
|
||||
await adminService.updateReportStatus(reportId, status);
|
||||
toast.success("Statut mis à jour.");
|
||||
fetchReports();
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors de la mise à jour du statut.");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ReportStatus) => {
|
||||
switch (status) {
|
||||
case ReportStatus.PENDING:
|
||||
return <Badge variant="outline">En attente</Badge>;
|
||||
case ReportStatus.REVIEWED:
|
||||
return <Badge variant="secondary">Examiné</Badge>;
|
||||
case ReportStatus.RESOLVED:
|
||||
return <Badge variant="success">Résolu</Badge>;
|
||||
case ReportStatus.DISMISSED:
|
||||
return <Badge variant="destructive">Rejeté</Badge>;
|
||||
default:
|
||||
return <Badge variant="default">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-8 p-4 pt-6 md:p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/admin">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Signalements</h2>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des signalements</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez les signalements de contenu inapproprié.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Signalé par</TableHead>
|
||||
<TableHead>Cible</TableHead>
|
||||
<TableHead>Raison</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
Chargement...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : reports.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
Aucun signalement trouvé.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
reports.map((report) => (
|
||||
<TableRow key={report.uuid}>
|
||||
<TableCell>{report.reporterId.substring(0, 8)}...</TableCell>
|
||||
<TableCell>
|
||||
{report.contentId ? (
|
||||
<Link
|
||||
href={`/meme/${report.contentId}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Contenu
|
||||
</Link>
|
||||
) : (
|
||||
"Tag"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium capitalize">
|
||||
{report.reason}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{report.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(report.status)}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(report.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleUpdateStatus(report.uuid, ReportStatus.REVIEWED)
|
||||
}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Marquer comme examiné
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleUpdateStatus(report.uuid, ReportStatus.RESOLVED)
|
||||
}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Marquer comme résolu
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleUpdateStatus(report.uuid, ReportStatus.DISMISSED)
|
||||
}
|
||||
className="text-destructive"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Rejeter
|
||||
</DropdownMenuItem>
|
||||
{report.contentId && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/meme/${report.contentId}`}>Voir le contenu</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,9 @@ export default function HelpPage() {
|
||||
<p className="text-muted-foreground">
|
||||
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
|
||||
</p>
|
||||
<p className="font-semibold text-primary">contact@memegoat.fr</p>
|
||||
<p className="font-semibold text-primary">
|
||||
{process.env.NEXT_PUBLIC_CONTACT_EMAIL || "contact@memegoat.fr"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ChevronLeft } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CommentSection } from "@/components/comment-section";
|
||||
import { ContentCard } from "@/components/content-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ViewCounter } from "@/components/view-counter";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
export const revalidate = 3600; // ISR: Revalider toutes les heures
|
||||
@@ -41,7 +41,6 @@ export default async function MemePage({
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<ViewCounter contentId={content.id} />
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-sm mb-6 hover:text-primary transition-colors"
|
||||
@@ -50,9 +49,10 @@ export default async function MemePage({
|
||||
Retour au flux
|
||||
</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">
|
||||
<ContentCard content={content} />
|
||||
<CommentSection contentId={content.id} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
562
frontend/src/app/(dashboard)/messages/page.tsx
Normal file
562
frontend/src/app/(dashboard)/messages/page.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Search,
|
||||
Send,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import {
|
||||
type Conversation,
|
||||
type Message,
|
||||
MessageService,
|
||||
} from "@/services/message.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { user } = useAuth();
|
||||
const { socket } = useSocket();
|
||||
const _router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const targetUserId = searchParams.get("user");
|
||||
|
||||
const [conversations, setConversations] = React.useState<Conversation[]>([]);
|
||||
const [activeConv, setActiveConv] = React.useState<Conversation | null>(null);
|
||||
const [messages, setMessages] = React.useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = React.useState("");
|
||||
const typingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleTyping = () => {
|
||||
if (!socket || !activeConv) return;
|
||||
|
||||
socket.emit("typing", {
|
||||
recipientId: activeConv.recipient.uuid,
|
||||
isTyping: true,
|
||||
});
|
||||
|
||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
socket.emit("typing", {
|
||||
recipientId: activeConv.recipient.uuid,
|
||||
isTyping: false,
|
||||
});
|
||||
}, 3000);
|
||||
};
|
||||
const [isLoadingConvs, setIsLoadingConvs] = React.useState(true);
|
||||
const [isLoadingMsgs, setIsLoadingMsgs] = React.useState(false);
|
||||
const [isOtherTyping, setIsOtherTyping] = React.useState(false);
|
||||
const [onlineUsers, setOnlineUsers] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [searchResults, setSearchResults] = React.useState<User[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Charger les conversations initiales
|
||||
React.useEffect(() => {
|
||||
const fetchConvs = async () => {
|
||||
try {
|
||||
const data = await MessageService.getConversations();
|
||||
setConversations(data);
|
||||
|
||||
// Si un utilisateur est spécifié dans l'URL, essayer de trouver la conversation
|
||||
if (targetUserId) {
|
||||
const existing = data.find((c) => c.recipient.uuid === targetUserId);
|
||||
if (existing) {
|
||||
setActiveConv(existing);
|
||||
} else {
|
||||
// Chercher les infos de l'utilisateur pour afficher une interface de chat vide
|
||||
try {
|
||||
const conv = await MessageService.getConversationWith(targetUserId);
|
||||
if (conv) {
|
||||
setConversations((prev) => [conv, ...prev]);
|
||||
setActiveConv(conv);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Peut-être que l'utilisateur n'existe pas ou erreur
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors du chargement des conversations");
|
||||
} finally {
|
||||
setIsLoadingConvs(false);
|
||||
}
|
||||
};
|
||||
fetchConvs();
|
||||
}, [targetUserId]);
|
||||
|
||||
// Recherche d'utilisateurs
|
||||
React.useEffect(() => {
|
||||
const delayDebounceFn = setTimeout(async () => {
|
||||
if (searchQuery.length > 1) {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await UserService.search(searchQuery);
|
||||
setSearchResults(results.filter((u) => u.uuid !== user?.uuid));
|
||||
} catch (_error) {
|
||||
console.error("Search failed");
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [searchQuery, user?.uuid]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeConv) {
|
||||
const fetchMsgs = async () => {
|
||||
setIsLoadingMsgs(true);
|
||||
try {
|
||||
const data = await MessageService.getMessages(activeConv.id);
|
||||
setMessages(data.reverse()); // Plus ancien au plus récent
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors du chargement des messages");
|
||||
} finally {
|
||||
setIsLoadingMsgs(false);
|
||||
}
|
||||
};
|
||||
fetchMsgs();
|
||||
}
|
||||
}, [activeConv]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on(
|
||||
"new_message",
|
||||
(data: { conversationId: string; message: Message }) => {
|
||||
if (activeConv?.id === data.conversationId) {
|
||||
setMessages((prev) => [...prev, data.message]);
|
||||
setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus
|
||||
// Marquer comme lu immédiatement si on est sur la conversation
|
||||
MessageService.markAsRead(data.conversationId).catch(console.error);
|
||||
}
|
||||
// Mettre à jour la liste des conversations
|
||||
setConversations((prev) => {
|
||||
const index = prev.findIndex((c) => c.id === data.conversationId);
|
||||
if (index !== -1) {
|
||||
const updated = [...prev];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
lastMessage: {
|
||||
text: data.message.text,
|
||||
createdAt: data.message.createdAt,
|
||||
},
|
||||
updatedAt: data.message.createdAt,
|
||||
};
|
||||
return updated.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
socket.on("user_status", (data: { userId: string; status: string }) => {
|
||||
setOnlineUsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (data.status === "online") {
|
||||
next.add(data.userId);
|
||||
} else {
|
||||
next.delete(data.userId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("user_typing", (data: { userId: string; isTyping: boolean }) => {
|
||||
if (activeConv?.recipient.uuid === data.userId) {
|
||||
setIsOtherTyping(data.isTyping);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"messages_read",
|
||||
(data: { conversationId: string; readerId: string }) => {
|
||||
if (activeConv?.id === data.conversationId) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.senderId !== data.readerId && !msg.readAt
|
||||
? { ...msg, readAt: new Date().toISOString() }
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off("new_message");
|
||||
socket.off("user_status");
|
||||
socket.off("user_typing");
|
||||
socket.off("messages_read");
|
||||
};
|
||||
}
|
||||
}, [socket, activeConv]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const scrollContainer = scrollRef.current.querySelector(
|
||||
"[data-slot='scroll-area-viewport']",
|
||||
);
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !activeConv) return;
|
||||
|
||||
const text = newMessage.trim();
|
||||
setNewMessage("");
|
||||
|
||||
try {
|
||||
const msg = await MessageService.sendMessage(
|
||||
activeConv.recipient.uuid,
|
||||
text,
|
||||
);
|
||||
|
||||
// Si c'était une conv temporaire, on la remplace par la vraie
|
||||
if (activeConv.id.startsWith("temp-")) {
|
||||
const fetchConvs = async () => {
|
||||
const data = await MessageService.getConversations();
|
||||
setConversations(data);
|
||||
const realConv = data.find(
|
||||
(c) => c.recipient.uuid === activeConv.recipient.uuid,
|
||||
);
|
||||
if (realConv) setActiveConv(realConv);
|
||||
};
|
||||
fetchConvs();
|
||||
} else {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors de l'envoi");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950">
|
||||
{/* Sidebar - Liste des conversations */}
|
||||
<div
|
||||
className={`w-full md:w-80 border-r flex flex-col ${
|
||||
activeConv ? "hidden md:flex" : "flex"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">Messages</h2>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher un membre..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{searchQuery.length > 0 ? (
|
||||
<>
|
||||
<p className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Membres
|
||||
</p>
|
||||
{isSearching ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Recherche...
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Aucun membre trouvé.
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((result) => (
|
||||
<button
|
||||
key={result.uuid}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSearchQuery("");
|
||||
// Chercher si une conv existe déjà
|
||||
const existing = conversations.find(
|
||||
(c) => c.recipient.uuid === result.uuid,
|
||||
);
|
||||
if (existing) {
|
||||
setActiveConv(existing);
|
||||
} else {
|
||||
// Créer une interface de conv temporaire
|
||||
const newConv: Conversation = {
|
||||
id: `temp-${result.uuid}`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
recipient: {
|
||||
uuid: result.uuid,
|
||||
username: result.username,
|
||||
displayName: result.displayName,
|
||||
avatarUrl: result.avatarUrl,
|
||||
},
|
||||
};
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
setActiveConv(newConv);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={result.avatarUrl} />
|
||||
<AvatarFallback>{result.username[0].toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 text-left overflow-hidden">
|
||||
<span className="font-bold block truncate">
|
||||
{result.displayName || result.username}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground block truncate">
|
||||
@{result.username}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : isLoadingConvs ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Chargement...
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Aucune conversation.
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
type="button"
|
||||
onClick={() => setActiveConv(conv)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-colors ${
|
||||
activeConv?.id === conv.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
<Avatar isOnline={onlineUsers.has(conv.recipient.uuid)}>
|
||||
<AvatarImage src={conv.recipient.avatarUrl} />
|
||||
<AvatarFallback>
|
||||
{conv.recipient.username[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 text-left overflow-hidden">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="font-bold truncate">
|
||||
{conv.recipient.displayName || conv.recipient.username}
|
||||
</span>
|
||||
{conv.lastMessage && (
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(conv.lastMessage.createdAt), {
|
||||
locale: fr,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{conv.lastMessage?.text || "Démarrer une conversation"}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Zone de chat */}
|
||||
<div
|
||||
className={`flex-1 flex flex-col ${
|
||||
!activeConv ? "hidden md:flex" : "flex"
|
||||
}`}
|
||||
>
|
||||
{activeConv ? (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden rounded-full"
|
||||
onClick={() => setActiveConv(null)}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<Link
|
||||
href={`/user/${activeConv.recipient.username}`}
|
||||
className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Avatar
|
||||
className="h-8 w-8"
|
||||
isOnline={onlineUsers.has(activeConv.recipient.uuid)}
|
||||
>
|
||||
<AvatarImage src={activeConv.recipient.avatarUrl} />
|
||||
<AvatarFallback>
|
||||
{activeConv.recipient.username[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="font-bold leading-none">
|
||||
{activeConv.recipient.displayName || activeConv.recipient.username}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
onlineUsers.has(activeConv.recipient.uuid)
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{onlineUsers.has(activeConv.recipient.uuid)
|
||||
? "En ligne"
|
||||
: "Hors ligne"}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4" viewportRef={scrollRef}>
|
||||
<div className="space-y-4">
|
||||
{isLoadingMsgs ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
Chargement...
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${
|
||||
msg.senderId === user?.uuid ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] p-3 rounded-2xl text-sm ${
|
||||
msg.senderId === user?.uuid
|
||||
? "bg-primary text-primary-foreground rounded-br-none"
|
||||
: "bg-zinc-100 dark:bg-zinc-800 rounded-bl-none"
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.text}</p>
|
||||
<div
|
||||
className={`flex items-center gap-1 text-[10px] mt-1 ${
|
||||
msg.senderId === user?.uuid
|
||||
? "text-primary-foreground/70 justify-end"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
{msg.senderId === user?.uuid && (
|
||||
<span className="flex items-center">
|
||||
{msg.readAt ? (
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isOtherTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-zinc-100 dark:bg-zinc-800 p-3 rounded-2xl rounded-bl-none">
|
||||
<div className="flex gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
|
||||
<span className="w-1.5 h-1.5 bg-zinc-400 rounded-full animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Écrivez un message..."
|
||||
value={newMessage}
|
||||
onChange={(e) => {
|
||||
setNewMessage(e.target.value);
|
||||
handleTyping();
|
||||
}}
|
||||
className="rounded-full px-4"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="rounded-full shrink-0"
|
||||
disabled={!newMessage.trim()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
||||
<div className="bg-primary/10 p-6 rounded-full mb-4">
|
||||
<Send className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">Vos messages</h2>
|
||||
<p className="text-muted-foreground max-w-sm">
|
||||
Sélectionnez une conversation ou démarrez-en une nouvelle pour commencer
|
||||
à discuter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Laptop,
|
||||
Loader2,
|
||||
Moon,
|
||||
Palette,
|
||||
Save,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
Trash2,
|
||||
User as UserIcon,
|
||||
@@ -19,6 +21,7 @@ import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { TwoFactorSetup } from "@/components/two-factor-setup";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -51,6 +54,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { UserService } from "@/services/user.service";
|
||||
@@ -58,6 +62,8 @@ import { UserService } from "@/services/user.service";
|
||||
const settingsSchema = z.object({
|
||||
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
|
||||
bio: z.string().max(255, "La bio est trop longue").optional(),
|
||||
showOnlineStatus: z.boolean(),
|
||||
showReadReceipts: z.boolean(),
|
||||
});
|
||||
|
||||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||
@@ -68,6 +74,7 @@ export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -79,6 +86,8 @@ export default function SettingsPage() {
|
||||
defaultValues: {
|
||||
displayName: "",
|
||||
bio: "",
|
||||
showOnlineStatus: true,
|
||||
showReadReceipts: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,6 +96,8 @@ export default function SettingsPage() {
|
||||
form.reset({
|
||||
displayName: user.displayName || "",
|
||||
bio: user.bio || "",
|
||||
showOnlineStatus: user.showOnlineStatus ?? true,
|
||||
showReadReceipts: user.showReadReceipts ?? true,
|
||||
});
|
||||
}
|
||||
}, [user, form]);
|
||||
@@ -143,6 +154,29 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const data = await UserService.exportData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", `memegoat-data-${user?.username}.json`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
toast.success("Vos données ont été exportées avec succès.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erreur lors de l'exportation des données.");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-12 px-4">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
@@ -239,6 +273,75 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confidentialité */}
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle>Confidentialité</CardTitle>
|
||||
<CardDescription>Gérez la visibilité de vos activités.</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showOnlineStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Statut en ligne</FormLabel>
|
||||
<FormDescription>
|
||||
Affiche quand vous êtes actif sur le site.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showReadReceipts"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Confirmations de lecture
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Permet aux autres de voir quand vous avez lu leurs messages.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TwoFactorSetup />
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -291,6 +394,49 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Download className="h-5 w-5 text-primary" />
|
||||
<CardTitle>Portabilité des données</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Conformément au RGPD, vous pouvez exporter l'intégralité de vos données
|
||||
rattachées à votre compte.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 rounded-lg bg-white dark:bg-zinc-900 border">
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold">Exporter mes données</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Téléchargez un fichier JSON contenant votre profil, vos mèmes et vos
|
||||
favoris.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportData}
|
||||
disabled={isExporting}
|
||||
className="font-semibold"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Exportation...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exporter mes données
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/20 shadow-sm bg-destructive/5">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
||||
@@ -36,13 +36,14 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import type { Category } from "@/types/content";
|
||||
|
||||
const uploadSchema = z.object({
|
||||
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(),
|
||||
tags: z.string().optional(),
|
||||
});
|
||||
@@ -52,10 +53,32 @@ type UploadFormValues = z.infer<typeof uploadSchema>;
|
||||
export default function UploadPage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { socket } = useSocket();
|
||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const [uploadStatus, setUploadStatus] = React.useState<string>("");
|
||||
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on(
|
||||
"upload_progress",
|
||||
(data: { status: string; progress: number; message?: string }) => {
|
||||
setUploadStatus(data.status);
|
||||
setUploadProgress(data.progress);
|
||||
if (data.status === "error" && data.message) {
|
||||
toast.error(data.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off("upload_progress");
|
||||
};
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const form = useForm<UploadFormValues>({
|
||||
resolver: zodResolver(uploadSchema),
|
||||
@@ -112,6 +135,16 @@ export default function UploadPage() {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
@@ -182,7 +215,7 @@ export default function UploadPage() {
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<FormLabel>Fichier (Image ou GIF)</FormLabel>
|
||||
<FormLabel>Fichier (Image, GIF ou Vidéo)</FormLabel>
|
||||
{!preview ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -194,25 +227,31 @@ export default function UploadPage() {
|
||||
</div>
|
||||
<p className="font-medium">Cliquez pour choisir un fichier</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PNG, JPG ou GIF jusqu'à 10Mo
|
||||
PNG, JPG, GIF, MP4 ou MOV jusqu'à 10Mo
|
||||
</p>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*,.gif"
|
||||
accept="image/*,video/mp4,video/webm,video/quicktime,.gif"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative rounded-lg overflow-hidden border bg-zinc-100 dark:bg-zinc-800">
|
||||
<div className="relative h-[400px] w-full">
|
||||
<NextImage
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
<div className="relative h-[400px] w-full flex items-center justify-center">
|
||||
{file?.type.startsWith("video/") ? (
|
||||
<video src={preview} controls className="max-h-full max-w-full">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
) : (
|
||||
<NextImage
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -260,6 +299,7 @@ export default function UploadPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value="meme">Image fixe</SelectItem>
|
||||
<SelectItem value="gif">GIF Animé</SelectItem>
|
||||
<SelectItem value="video">Vidéo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -310,10 +350,20 @@ export default function UploadPage() {
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Upload en cours...
|
||||
</>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-wider opacity-70">
|
||||
{uploadStatus === "starting" && "Initialisation..."}
|
||||
{uploadStatus === "scanning" && "Scan Antivirus..."}
|
||||
{uploadStatus === "processing" && "Optimisation..."}
|
||||
{uploadStatus === "uploading_s3" && "Envoi au cloud..."}
|
||||
{uploadStatus === "saving" && "Finalisation..."}
|
||||
{uploadStatus === "completed" && "Terminé !"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
"Publier le mème"
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar, Share2, User as UserIcon } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
MessageCircle,
|
||||
Share2,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ContentList } from "@/components/content-list";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import type { User } from "@/types/user";
|
||||
@@ -17,9 +24,12 @@ export default function PublicProfilePage({
|
||||
params: Promise<{ username: string }>;
|
||||
}) {
|
||||
const { username } = React.use(params);
|
||||
const { user: currentUser, isAuthenticated } = useAuth();
|
||||
const [user, setUser] = React.useState<User | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
const isOwnProfile = currentUser?.username === username;
|
||||
|
||||
React.useEffect(() => {
|
||||
UserService.getProfile(username)
|
||||
.then(setUser)
|
||||
@@ -93,7 +103,15 @@ export default function PublicProfilePage({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center md:justify-start pt-2">
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-2 pt-2">
|
||||
{!isOwnProfile && isAuthenticated && (
|
||||
<Button size="sm" className="h-9 px-4" asChild>
|
||||
<Link href={`/messages?user=${user.uuid}`}>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Message
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.9821 0 0);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
|
||||
import { NotificationHandler } from "@/components/notification-handler";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AudioProvider } from "@/providers/audio-provider";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
import { SocketProvider } from "@/providers/socket-provider";
|
||||
import { ThemeProvider } from "@/providers/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -29,7 +32,7 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fr_FR",
|
||||
url: "https://memegoat.local",
|
||||
url: "/",
|
||||
siteName: "MemeGoat",
|
||||
title: "MemeGoat | Partagez vos meilleurs mèmes",
|
||||
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
|
||||
@@ -71,8 +74,13 @@ export default function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
<SocketProvider>
|
||||
<AudioProvider>
|
||||
{children}
|
||||
<NotificationHandler />
|
||||
<Toaster />
|
||||
</AudioProvider>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
LayoutGrid,
|
||||
LogIn,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import * as React from "react";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -42,14 +45,19 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import { MessageService } from "@/services/message.service";
|
||||
import type { Category } from "@/types/content";
|
||||
|
||||
const mainNav = [
|
||||
@@ -74,19 +82,74 @@ export function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
const { socket } = useSocket();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [unreadMessages, setUnreadMessages] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Gérer le compteur de messages non-lus
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
MessageService.getUnreadCount().then(setUnreadMessages).catch(console.error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (socket && isAuthenticated) {
|
||||
socket.on("new_message", () => {
|
||||
// Incrémenter si on n'est pas sur la page messages
|
||||
if (pathname !== "/messages") {
|
||||
setUnreadMessages((prev) => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off("new_message");
|
||||
};
|
||||
}
|
||||
}, [socket, isAuthenticated, pathname]);
|
||||
|
||||
// Remettre à zéro si on arrive sur la page messages
|
||||
React.useEffect(() => {
|
||||
if (pathname === "/messages") {
|
||||
setUnreadMessages(0);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const logoSrc = React.useMemo(() => {
|
||||
if (!mounted) return "/memegoat-color.svg";
|
||||
return resolvedTheme === "dark"
|
||||
? "/memegoat-white.svg"
|
||||
: "/memegoat-black.svg";
|
||||
}, [resolvedTheme, mounted]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="flex items-center justify-center py-4">
|
||||
<Link href="/" className="flex items-center gap-2 font-bold text-xl">
|
||||
<div className="bg-primary text-primary-foreground p-1 rounded">🐐</div>
|
||||
<span className="group-data-[collapsible=icon]:hidden">MemeGoat</span>
|
||||
<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 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>
|
||||
<SidebarTrigger className="hidden md:flex group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
@@ -152,6 +215,25 @@ export function AppSidebar() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{isAuthenticated && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === "/messages"}
|
||||
tooltip="Messages"
|
||||
>
|
||||
<Link href="/messages">
|
||||
<MessageCircle />
|
||||
<span>Messages</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{unreadMessages > 0 && (
|
||||
<SidebarMenuBadge className="bg-red-500 text-white border-none h-5 min-w-5 flex items-center justify-center p-1 text-[10px]">
|
||||
{unreadMessages > 9 ? "9+" : unreadMessages}
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -305,6 +387,7 @@ export function AppSidebar() {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
314
frontend/src/components/comment-section.tsx
Normal file
314
frontend/src/components/comment-section.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Heart, MoreHorizontal, Send, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { type Comment, CommentService } from "@/services/comment.service";
|
||||
|
||||
interface CommentSectionProps {
|
||||
contentId: string;
|
||||
}
|
||||
|
||||
export function CommentSection({ contentId }: CommentSectionProps) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { socket } = useSocket();
|
||||
const [comments, setComments] = React.useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = React.useState("");
|
||||
const [replyingTo, setReplyingTo] = React.useState<Comment | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const fetchComments = React.useCallback(async () => {
|
||||
try {
|
||||
const data = await CommentService.getByContentId(contentId);
|
||||
setComments(data);
|
||||
} catch (_error) {
|
||||
toast.error("Impossible de charger les commentaires");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [contentId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchComments();
|
||||
}, [fetchComments]);
|
||||
|
||||
// Gestion du WebSocket
|
||||
React.useEffect(() => {
|
||||
if (socket) {
|
||||
socket.emit("join_content", contentId);
|
||||
|
||||
socket.on("new_comment", (comment: Comment) => {
|
||||
setComments((prev) => {
|
||||
// Éviter les doublons si l'auteur reçoit son propre commentaire via WS
|
||||
if (prev.some((c) => c.id === comment.id)) return prev;
|
||||
return [comment, ...prev];
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.emit("leave_content", contentId);
|
||||
socket.off("new_comment");
|
||||
};
|
||||
}
|
||||
}, [socket, contentId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newComment.trim() || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const comment = await CommentService.create(
|
||||
contentId,
|
||||
newComment.trim(),
|
||||
replyingTo?.id,
|
||||
);
|
||||
setComments((prev) => [comment, ...prev]);
|
||||
setNewComment("");
|
||||
setReplyingTo(null);
|
||||
toast.success("Commentaire publié !");
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors de la publication du commentaire");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
try {
|
||||
await CommentService.remove(commentId);
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
toast.success("Commentaire supprimé");
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors de la suppression");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async (comment: Comment) => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error("Vous devez être connecté pour liker");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (comment.isLiked) {
|
||||
await CommentService.unlike(comment.id);
|
||||
setComments((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === comment.id
|
||||
? { ...c, isLiked: false, likesCount: c.likesCount - 1 }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await CommentService.like(comment.id);
|
||||
setComments((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === comment.id
|
||||
? { ...c, isLiked: true, likesCount: c.likesCount + 1 }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error("Une erreur est survenue");
|
||||
}
|
||||
};
|
||||
|
||||
// Organiser les commentaires : Parents d'abord
|
||||
const rootComments = comments.filter((c) => !c.parentId);
|
||||
|
||||
const renderComment = (comment: Comment, depth = 0) => {
|
||||
const replies = comments.filter((c) => c.parentId === comment.id);
|
||||
|
||||
return (
|
||||
<div key={comment.id} className={cn("space-y-4", depth > 0 && "ml-10")}>
|
||||
<div className="flex gap-3">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarImage src={comment.user.avatarUrl} />
|
||||
<AvatarFallback>{comment.user.username[0].toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold">
|
||||
{comment.user.displayName || comment.user.username}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(comment.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: fr,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
comment.isLiked && "text-red-500 hover:text-red-600",
|
||||
)}
|
||||
onClick={() => handleLike(comment)}
|
||||
>
|
||||
<Heart className={cn("h-4 w-4", comment.isLiked && "fill-current")} />
|
||||
</Button>
|
||||
{(user?.uuid === comment.user.uuid ||
|
||||
user?.role === "admin" ||
|
||||
user?.role === "moderator") && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{comment.text}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
{comment.likesCount > 0 && (
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{comment.likesCount} like{comment.likesCount > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{isAuthenticated && depth < 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs font-semibold text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
onClick={() => {
|
||||
setReplyingTo(comment);
|
||||
setNewComment(`@${comment.user.username} `);
|
||||
document.querySelector("textarea")?.focus();
|
||||
}}
|
||||
>
|
||||
Répondre
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
<div className="space-y-4 pt-2">
|
||||
{replies.map((reply) => renderComment(reply, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-lg">Commentaires ({comments.length})</h3>
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<div className="space-y-2">
|
||||
{replyingTo && (
|
||||
<div className="flex items-center justify-between bg-zinc-100 dark:bg-zinc-800 px-3 py-1.5 rounded-lg text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
En réponse à{" "}
|
||||
<span className="font-bold">@{replyingTo.user.username}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4"
|
||||
onClick={() => {
|
||||
setReplyingTo(null);
|
||||
setNewComment("");
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarImage src={user?.avatarUrl} />
|
||||
<AvatarFallback>{user?.username[0].toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Textarea
|
||||
placeholder={
|
||||
replyingTo ? "Ajouter une réponse..." : "Ajouter un commentaire..."
|
||||
}
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
className="min-h-[80px] resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{replyingTo && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReplyingTo(null);
|
||||
setNewComment("");
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Envoi..." : replyingTo ? "Répondre" : "Publier"}
|
||||
<Send className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-zinc-100 dark:bg-zinc-800 p-4 rounded-xl text-center text-sm">
|
||||
Connectez-vous pour laisser un commentaire.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-4">Chargement...</div>
|
||||
) : rootComments.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
Aucun commentaire pour le moment. Soyez le premier !
|
||||
</div>
|
||||
) : (
|
||||
rootComments.map((comment) => renderComment(comment))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Edit, Eye, Heart, MoreHorizontal, Share2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Edit,
|
||||
Eye,
|
||||
Flag,
|
||||
Heart,
|
||||
MoreHorizontal,
|
||||
Share2,
|
||||
Trash2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -21,17 +30,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAudio } from "@/providers/audio-provider";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
import { FavoriteService } from "@/services/favorite.service";
|
||||
import type { Content } from "@/types/content";
|
||||
import { ShareDialog } from "./share-dialog";
|
||||
import { UserContentEditDialog } from "./user-content-edit-dialog";
|
||||
import { ViewCounter } from "./view-counter";
|
||||
|
||||
interface ContentCardProps {
|
||||
content: Content;
|
||||
@@ -40,12 +46,36 @@ interface ContentCardProps {
|
||||
|
||||
export function ContentCard({ content, onUpdate }: ContentCardProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { isGlobalMuted, activeVideoId, toggleGlobalMute, setActiveVideo } =
|
||||
useAudio();
|
||||
const router = useRouter();
|
||||
const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
|
||||
const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
|
||||
const [editDialogOpen, setEditDialogOpen] = React.useState(false);
|
||||
const [shareDialogOpen, setShareDialogOpen] = React.useState(false);
|
||||
const [_reportDialogOpen, setReportDialogOpen] = React.useState(false);
|
||||
|
||||
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(() => {
|
||||
setIsLiked(content.isLiked || false);
|
||||
@@ -71,12 +101,26 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
|
||||
await FavoriteService.add(content.id);
|
||||
setIsLiked(true);
|
||||
setLikesCount((prev) => prev + 1);
|
||||
// Considérer un like comme une vue
|
||||
ContentService.incrementViews(content.id).catch(() => {});
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error("Une erreur est survenue");
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -107,9 +151,10 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader className="p-4 flex flex-row items-center space-y-0 gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<ViewCounter contentId={content.id} videoRef={videoRef} />
|
||||
<Card className="overflow-hidden border-none gap-0 shadow-none bg-transparent">
|
||||
<CardHeader className="p-3 flex flex-row items-center space-y-0 gap-3">
|
||||
<Avatar className="h-8 w-8 border">
|
||||
<AvatarImage src={content.author.avatarUrl} />
|
||||
<AvatarFallback>
|
||||
{content.author.username[0].toUpperCase()}
|
||||
@@ -118,13 +163,10 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
|
||||
<div className="flex flex-col">
|
||||
<Link
|
||||
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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(content.createdAt).toLocaleDateString("fr-FR")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
@@ -150,123 +192,138 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => toast.success("Lien copié !")}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error("Connectez-vous pour partager");
|
||||
return;
|
||||
}
|
||||
setShareDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Partager
|
||||
</DropdownMenuItem>
|
||||
{!isAuthor && (
|
||||
<DropdownMenuItem onClick={() => setReportDialogOpen(true)}>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Signaler
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</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">
|
||||
<Link href={`/meme/${content.slug}`} className="w-full h-full relative">
|
||||
<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 block relative"
|
||||
>
|
||||
{content.mimeType.startsWith("image/") ? (
|
||||
<Image
|
||||
src={content.url}
|
||||
alt={content.title}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
width={content.width || 1000}
|
||||
height={content.height || 1000}
|
||||
className="w-full h-full object-contain"
|
||||
priority={false}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={content.url}
|
||||
controls={false}
|
||||
autoPlay
|
||||
muted
|
||||
autoPlay={isThisVideoActive}
|
||||
muted={isMuted}
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<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="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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" : ""}`}
|
||||
onClick={handleLike}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${isLiked ? "fill-current" : ""}`} />
|
||||
<span className="text-xs font-medium">{likesCount}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Liker</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2 cursor-default"
|
||||
>
|
||||
<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 className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLike}
|
||||
className={`transition-transform active:scale-125 ${isLiked ? "text-red-500" : "hover:text-muted-foreground"}`}
|
||||
>
|
||||
<Heart className={`h-6 w-6 ${isLiked ? "fill-current" : ""}`} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Eye className="h-6 w-6" />
|
||||
<span className="text-sm font-medium">{content.views}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error("Connectez-vous pour partager");
|
||||
return;
|
||||
}
|
||||
setShareDialogOpen(true);
|
||||
}}
|
||||
className="hover:text-muted-foreground"
|
||||
>
|
||||
<Share2 className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="text-xs h-8 font-semibold"
|
||||
className="text-xs h-8 font-semibold rounded-full px-4"
|
||||
onClick={handleUse}
|
||||
>
|
||||
Utiliser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<Link href={`/meme/${content.slug}`}>
|
||||
<h3 className="font-semibold text-base line-clamp-2 hover:text-primary transition-colors">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-bold">{likesCount} J'aime</p>
|
||||
<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}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{content.category && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 px-2 bg-muted/50">
|
||||
{content.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
{content.tags.slice(0, 3).map((tag, _i) => (
|
||||
<Badge
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{content.tags.slice(0, 5).map((tag, _i) => (
|
||||
<Link
|
||||
key={typeof tag === "string" ? tag : tag.id}
|
||||
variant="secondary"
|
||||
className="text-[10px] py-0 px-2"
|
||||
href={`/?tag=${typeof tag === "string" ? tag : tag.slug}`}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
#{typeof tag === "string" ? tag : tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground uppercase mt-1">
|
||||
{new Date(content.createdAt).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -276,6 +333,13 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
|
||||
onOpenChange={setEditDialogOpen}
|
||||
onSuccess={() => onUpdate?.()}
|
||||
/>
|
||||
<ShareDialog
|
||||
contentId={content.id}
|
||||
contentTitle={content.title}
|
||||
contentUrl={`${typeof window !== "undefined" ? window.location.origin : ""}/meme/${content.slug}`}
|
||||
open={shareDialogOpen}
|
||||
onOpenChange={setShareDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useInfiniteScroll } from "@/app/(dashboard)/_hooks/use-infinite-scroll";
|
||||
import { ContentCard } from "@/components/content-card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAudio } from "@/providers/audio-provider";
|
||||
import type { Content, PaginatedResponse } from "@/types/content";
|
||||
|
||||
interface ContentListProps {
|
||||
@@ -15,10 +16,48 @@ interface ContentListProps {
|
||||
}
|
||||
|
||||
export function ContentList({ fetchFn, title }: ContentListProps) {
|
||||
const { setActiveVideo } = useAudio();
|
||||
const [contents, setContents] = React.useState<Content[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -51,7 +90,11 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
|
||||
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);
|
||||
setHasMore(response.data.length === 10);
|
||||
} catch (error) {
|
||||
@@ -68,11 +111,20 @@ export function ContentList({ fetchFn, title }: ContentListProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-8">
|
||||
{title && <h1 className="text-2xl font-bold">{title}</h1>}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div
|
||||
ref={containerRef}
|
||||
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) => (
|
||||
<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>
|
||||
|
||||
|
||||
108
frontend/src/components/notification-handler.tsx
Normal file
108
frontend/src/components/notification-handler.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Heart, MessageCircle, Reply } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
|
||||
interface NotificationData {
|
||||
type: "comment" | "reply" | "like_comment" | "message";
|
||||
userId: string;
|
||||
username: string;
|
||||
contentId?: string;
|
||||
commentId?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function NotificationHandler() {
|
||||
const { socket } = useSocket();
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleNotification = (data: NotificationData) => {
|
||||
// Ne pas afficher de toast si on est déjà sur la page des messages pour un nouveau message
|
||||
if (data.type === "message" && window.location.pathname === "/messages") {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-start gap-3 bg-white dark:bg-zinc-900 p-4 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-800 w-full max-w-sm cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors text-left"
|
||||
onClick={() => {
|
||||
toast.dismiss(t);
|
||||
if (data.type === "message") {
|
||||
router.push("/messages");
|
||||
} else if (data.contentId) {
|
||||
router.push(`/meme/${data.contentId}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary/10 p-2 rounded-full shrink-0">
|
||||
{data.type === "comment" && (
|
||||
<MessageCircle className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
{data.type === "reply" && <Reply className="h-4 w-4 text-primary" />}
|
||||
{data.type === "like_comment" && (
|
||||
<Heart className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
{data.type === "message" && (
|
||||
<MessageCircle className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-bold">@{data.username}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{data.text}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toast.dismiss(t);
|
||||
}}
|
||||
>
|
||||
<Bell className="h-3 w-3" />
|
||||
</button>
|
||||
</button>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
position: "top-right",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
socket.on("notification", handleNotification);
|
||||
|
||||
// Aussi pour les nouveaux messages (si on veut un toast global)
|
||||
socket.on(
|
||||
"new_message",
|
||||
(data: { message: { text: string; sender?: { username: string } } }) => {
|
||||
if (window.location.pathname !== "/messages") {
|
||||
toast(
|
||||
`Nouveau message de @${data.message.sender?.username || "un membre"}`,
|
||||
{
|
||||
description: data.message.text.substring(0, 50),
|
||||
action: {
|
||||
label: "Voir",
|
||||
onClick: () => router.push("/messages"),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off("notification");
|
||||
socket.off("new_message");
|
||||
};
|
||||
}, [socket, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
119
frontend/src/components/report-dialog.tsx
Normal file
119
frontend/src/components/report-dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ReportReason, ReportService } from "@/services/report.service";
|
||||
|
||||
interface ReportDialogProps {
|
||||
contentId?: string;
|
||||
tagId?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ReportDialog({
|
||||
contentId,
|
||||
tagId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ReportDialogProps) {
|
||||
const [reason, setReason] = useState<ReportReason>(ReportReason.INAPPROPRIATE);
|
||||
const [description, setDescription] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await ReportService.create({
|
||||
contentId,
|
||||
tagId,
|
||||
reason,
|
||||
description,
|
||||
});
|
||||
toast.success(
|
||||
"Signalement envoyé avec succès. Merci de nous aider à maintenir la communauté sûre.",
|
||||
);
|
||||
onOpenChange(false);
|
||||
setDescription("");
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors de l'envoi du signalement.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Signaler le contenu</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pourquoi signalez-vous ce contenu ? Un modérateur examinera votre demande.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reason">Raison</Label>
|
||||
<Select
|
||||
value={reason}
|
||||
onValueChange={(value) => setReason(value as ReportReason)}
|
||||
>
|
||||
<SelectTrigger id="reason">
|
||||
<SelectValue placeholder="Sélectionnez une raison" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ReportReason.INAPPROPRIATE}>Inapproprié</SelectItem>
|
||||
<SelectItem value={ReportReason.SPAM}>Spam</SelectItem>
|
||||
<SelectItem value={ReportReason.COPYRIGHT}>Droit d'auteur</SelectItem>
|
||||
<SelectItem value={ReportReason.OTHER}>Autre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description (optionnelle)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Détaillez votre signalement..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Envoi..." : "Signaler"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Filter, Search } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Filter, Search } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CategoryService } from "@/services/category.service";
|
||||
import { TagService } from "@/services/tag.service";
|
||||
import type { Category, Tag } from "@/types/content";
|
||||
@@ -16,10 +22,24 @@ export function SearchSidebar() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(true);
|
||||
const [categories, setCategories] = React.useState<Category[]>([]);
|
||||
const [popularTags, setPopularTags] = React.useState<Tag[]>([]);
|
||||
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(() => {
|
||||
CategoryService.getAll().then(setCategories).catch(console.error);
|
||||
TagService.getAll({ limit: 10, sort: "popular" })
|
||||
@@ -51,98 +71,149 @@ export function SearchSidebar() {
|
||||
const currentCategory = searchParams.get("category");
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex flex-col w-80 border-l bg-background">
|
||||
<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>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtres
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<aside
|
||||
className={`hidden lg:flex flex-col border-l bg-background transition-all duration-300 relative ${
|
||||
isCollapsed ? "w-12" : "w-80"
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute -left-4 top-20 h-8 w-8 rounded-full bg-background shadow-md z-50 hover:bg-accent"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isCollapsed ? (
|
||||
<div className="flex flex-col items-center py-4 gap-4 overflow-hidden">
|
||||
<Tooltip>
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground mb-2">Trier par</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant={currentSort === "trend" ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => updateSearch("sort", "trend")}
|
||||
>
|
||||
Tendances
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={currentSort === "recent" ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => updateSearch("sort", "recent")}
|
||||
>
|
||||
Récent
|
||||
</Badge>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtres
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">Trier par</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant={currentSort === "trend" ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => updateSearch("sort", "trend")}
|
||||
>
|
||||
Tendances
|
||||
</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>
|
||||
<Separator />
|
||||
|
||||
<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">
|
||||
<Badge
|
||||
variant={!currentCategory ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => updateSearch("category", null)}
|
||||
>
|
||||
Tout
|
||||
</Badge>
|
||||
{categories.map((cat) => (
|
||||
{popularTags.map((tag) => (
|
||||
<Badge
|
||||
key={cat.id}
|
||||
variant={currentCategory === cat.slug ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => updateSearch("category", cat.slug)}
|
||||
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,
|
||||
)
|
||||
}
|
||||
>
|
||||
{cat.name}
|
||||
#{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{popularTags.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Aucun tag trouvé.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
186
frontend/src/components/share-dialog.tsx
Normal file
186
frontend/src/components/share-dialog.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { Search, Send, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MessageService } from "@/services/message.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
interface ShareDialogProps {
|
||||
contentId: string;
|
||||
contentTitle: string;
|
||||
contentUrl: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ShareDialog({
|
||||
contentId,
|
||||
contentTitle,
|
||||
contentUrl: _unused, // Support legacy prop
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ShareDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [results, setResults] = React.useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [sendingTo, setSendingTo] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInitial = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Par défaut, montrer les conversations récentes ou suggérer des gens
|
||||
const recent = await UserService.search("");
|
||||
setResults(recent);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchInitial();
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchQuery.length < 2) return;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await UserService.search(searchQuery);
|
||||
setResults(data);
|
||||
} catch (error) {
|
||||
console.error("Search failed", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleShare = async (user: User) => {
|
||||
setSendingTo(user.uuid);
|
||||
try {
|
||||
const shareUrl = `${window.location.origin}/meme/${contentId}`;
|
||||
await MessageService.sendMessage(
|
||||
user.uuid,
|
||||
`Regarde ce mème : ${contentTitle}\n${shareUrl}`,
|
||||
);
|
||||
toast.success(`Partagé avec @${user.username}`);
|
||||
onOpenChange(false);
|
||||
} catch (_error) {
|
||||
toast.error("Échec du partage");
|
||||
} finally {
|
||||
setSendingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px] p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="p-4 border-b">
|
||||
<DialogTitle>Partager avec</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Rechercher un membre..."
|
||||
className="pl-9 h-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="p-2 space-y-1">
|
||||
{isLoading && results.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Chargement...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
Aucun membre trouvé.
|
||||
</div>
|
||||
) : (
|
||||
results.map((user) => (
|
||||
<div
|
||||
key={user.uuid}
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback>{user.username[0].toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold leading-none">
|
||||
{user.displayName || user.username}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@{user.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={sendingTo === user.uuid ? "outline" : "default"}
|
||||
disabled={sendingTo !== null}
|
||||
onClick={() => handleShare(user)}
|
||||
className="h-8 px-4 rounded-full"
|
||||
>
|
||||
{sendingTo === user.uuid ? "Envoi..." : "Envoyer"}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="p-4 border-t bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 h-10 rounded-xl"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/meme/${contentId}`,
|
||||
);
|
||||
toast.success("Lien copié !");
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Copier le lien
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
294
frontend/src/components/two-factor-setup.tsx
Normal file
294
frontend/src/components/two-factor-setup.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Shield, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
export function TwoFactorSetup() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [step, setStep] = useState<"idle" | "setup" | "verify">("idle");
|
||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRevealed, setIsRevealed] = useState(false);
|
||||
|
||||
const handleSetup = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await AuthService.setup2fa();
|
||||
setQrCode(data.qrCodeUrl);
|
||||
setSecret(data.secret);
|
||||
setStep("setup");
|
||||
} catch (_error) {
|
||||
toast.error("Erreur lors de la configuration de la 2FA.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
if (otpValue.length !== 6) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await AuthService.enable2fa(otpValue);
|
||||
toast.success("Double authentification activée !");
|
||||
await refreshUser();
|
||||
setStep("idle");
|
||||
setOtpValue("");
|
||||
} catch (_error) {
|
||||
toast.error("Code invalide. Veuillez réessayer.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (otpValue.length !== 6) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await AuthService.disable2fa(otpValue);
|
||||
toast.success("Double authentification désactivée.");
|
||||
await refreshUser();
|
||||
setStep("idle");
|
||||
setOtpValue("");
|
||||
} catch (_error) {
|
||||
toast.error("Code invalide. Veuillez réessayer.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Note: We need a way to know if 2FA is enabled.
|
||||
const isEnabled = user?.twoFactorEnabled;
|
||||
|
||||
if (step === "idle") {
|
||||
return (
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<CardTitle>Double Authentification (2FA)</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Ajoutez une couche de sécurité supplémentaire à votre compte en utilisant
|
||||
une application d'authentification.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-zinc-50 dark:bg-zinc-900 border">
|
||||
{isEnabled ? (
|
||||
<>
|
||||
<div className="bg-green-100 dark:bg-green-900/30 p-2 rounded-full">
|
||||
<ShieldCheck className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold">La 2FA est activée</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Votre compte est protégé par un code temporaire.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setStep("verify")}>
|
||||
Désactiver
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-zinc-200 dark:bg-zinc-800 p-2 rounded-full">
|
||||
<ShieldAlert className="h-6 w-6 text-zinc-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold">La 2FA n'est pas activée</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Activez la 2FA pour mieux protéger votre compte.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSetup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Configurer"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "setup") {
|
||||
return (
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Configurer la 2FA</CardTitle>
|
||||
<CardDescription>
|
||||
Scannez le QR Code ci-dessous avec votre application d'authentification
|
||||
(Google Authenticator, Authy, etc.).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
{qrCode && (
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`bg-white p-4 rounded-xl border-4 border-zinc-100 transition-all duration-300 ${
|
||||
!isRevealed ? "blur-md select-none" : ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={qrCode}
|
||||
alt="QR Code 2FA"
|
||||
width={192}
|
||||
height={192}
|
||||
className="w-48 h-48"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
{!isRevealed && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setIsRevealed(true)}
|
||||
className="shadow-lg"
|
||||
>
|
||||
Afficher le QR Code
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-sm font-medium text-center">
|
||||
Ou entrez ce code manuellement :
|
||||
</p>
|
||||
<div className="relative group">
|
||||
<code
|
||||
className={`block p-2 bg-muted text-center rounded text-xs font-mono break-all transition-all duration-300 ${
|
||||
!isRevealed ? "blur-[3px] select-none" : ""
|
||||
}`}
|
||||
>
|
||||
{secret}
|
||||
</code>
|
||||
{!isRevealed && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRevealed(true)}
|
||||
className="text-[10px] font-bold uppercase tracking-wider text-primary hover:underline"
|
||||
>
|
||||
Afficher le code
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-4 w-full border-t pt-6">
|
||||
<p className="text-sm font-medium">
|
||||
Entrez le code à 6 chiffres pour confirmer :
|
||||
</p>
|
||||
<InputOTP maxLength={6} value={otpValue} onChange={setOtpValue}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="ghost" onClick={() => setStep("idle")}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
disabled={otpValue.length !== 6 || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Activer la 2FA"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "verify") {
|
||||
return (
|
||||
<Card className="border-none shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Désactiver la 2FA</CardTitle>
|
||||
<CardDescription>
|
||||
Veuillez entrer le code de votre application pour désactiver la double
|
||||
authentification.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
<InputOTP maxLength={6} value={otpValue} onChange={setOtpValue}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button variant="ghost" onClick={() => setStep("idle")}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisable}
|
||||
disabled={otpValue.length !== 6 || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Confirmer la désactivation"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
isOnline,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & { isOnline?: boolean }) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
<div className="relative inline-block">
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{isOnline && (
|
||||
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-white dark:ring-zinc-900" />
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import { cn } from "@/lib/utils";
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
viewportRef,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
@@ -18,6 +21,7 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
ref={viewportRef}
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,23 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
import { ContentService } from "@/services/content.service";
|
||||
|
||||
interface ViewCounterProps {
|
||||
contentId: string;
|
||||
videoRef?: RefObject<HTMLVideoElement | null>;
|
||||
}
|
||||
|
||||
export function ViewCounter({ contentId }: ViewCounterProps) {
|
||||
export function ViewCounter({ contentId, videoRef }: ViewCounterProps) {
|
||||
const hasIncremented = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasIncremented.current) {
|
||||
ContentService.incrementViews(contentId).catch((err) => {
|
||||
console.error("Failed to increment views:", err);
|
||||
});
|
||||
hasIncremented.current = true;
|
||||
}
|
||||
}, [contentId]);
|
||||
const increment = () => {
|
||||
if (!hasIncremented.current) {
|
||||
ContentService.incrementViews(contentId).catch((err) => {
|
||||
console.error("Failed to increment views:", err);
|
||||
});
|
||||
hasIncremented.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
return null;
|
||||
// 1. Observer pour la visibilité (IntersectionObserver)
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
// Si c'est une image (pas de videoRef), on attend 3 secondes
|
||||
if (!videoRef) {
|
||||
const timer = setTimeout(() => {
|
||||
increment();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 },
|
||||
);
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
// 2. Logique pour la vidéo (> 50%)
|
||||
let videoElement: HTMLVideoElement | null = null;
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoElement && videoElement.duration > 0) {
|
||||
const progress = videoElement.currentTime / videoElement.duration;
|
||||
if (progress >= 0.5) {
|
||||
increment();
|
||||
videoElement.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (videoRef?.current) {
|
||||
videoElement = videoRef.current;
|
||||
videoElement.addEventListener("timeupdate", handleTimeUpdate);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (videoElement) {
|
||||
videoElement.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
}
|
||||
};
|
||||
}, [contentId, videoRef]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="absolute inset-0 pointer-events-none" />
|
||||
);
|
||||
}
|
||||
|
||||
45
frontend/src/providers/audio-provider.tsx
Normal file
45
frontend/src/providers/audio-provider.tsx
Normal 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;
|
||||
}
|
||||
@@ -5,14 +5,15 @@ import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
import { UserService } from "@/services/user.service";
|
||||
import type { RegisterPayload } from "@/types/auth";
|
||||
import type { LoginResponse, RegisterPayload } from "@/types/auth";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<LoginResponse>;
|
||||
verify2fa: (userId: string, token: string) => Promise<void>;
|
||||
register: (payload: RegisterPayload) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
@@ -59,12 +60,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
await AuthService.login(email, password);
|
||||
const response = await AuthService.login(email, password);
|
||||
if (response.userId && response.message === "Please provide 2FA token") {
|
||||
return response;
|
||||
}
|
||||
await refreshUser();
|
||||
toast.success("Connexion réussie !");
|
||||
router.push("/");
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "Erreur de connexion";
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"response" in error &&
|
||||
error.response &&
|
||||
typeof error.response === "object" &&
|
||||
"data" in error.response &&
|
||||
error.response.data &&
|
||||
typeof error.response.data === "object" &&
|
||||
"message" in error.response.data &&
|
||||
typeof error.response.data.message === "string"
|
||||
) {
|
||||
errorMessage = error.response.data.message;
|
||||
}
|
||||
toast.error(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const verify2fa = async (userId: string, token: string) => {
|
||||
try {
|
||||
await AuthService.verify2fa(userId, token);
|
||||
await refreshUser();
|
||||
toast.success("Connexion réussie !");
|
||||
router.push("/");
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "Erreur de connexion";
|
||||
let errorMessage = "Code 2FA invalide";
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
@@ -130,6 +162,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
verify2fa,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
|
||||
66
frontend/src/providers/socket-provider.tsx
Normal file
66
frontend/src/providers/socket-provider.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import { useAuth } from "./auth-provider";
|
||||
|
||||
interface SocketContextType {
|
||||
socket: Socket | null;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
const SocketContext = React.createContext<SocketContextType>({
|
||||
socket: null,
|
||||
isConnected: false,
|
||||
});
|
||||
|
||||
export const useSocket = () => React.useContext(SocketContext);
|
||||
|
||||
export function SocketProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [socket, setSocket] = React.useState<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
|
||||
|
||||
// Initialisation du socket avec configuration optimisée pour la production
|
||||
const socketInstance = io(apiUrl, {
|
||||
withCredentials: true,
|
||||
transports: ["websocket"], // Recommandé pour éviter les problèmes de sticky sessions
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
});
|
||||
|
||||
socketInstance.on("connect", () => {
|
||||
console.log("WebSocket connected to:", apiUrl);
|
||||
setIsConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on("connect_error", (error) => {
|
||||
console.error("WebSocket connection error:", error);
|
||||
// Si le websocket pur échoue, on peut tenter le polling en fallback (optionnel)
|
||||
});
|
||||
|
||||
socketInstance.on("disconnect", () => {
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
return () => {
|
||||
socketInstance.disconnect();
|
||||
};
|
||||
} else {
|
||||
setSocket(null);
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={{ socket, isConnected }}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import api from "@/lib/api";
|
||||
import type { User } from "@/types/user";
|
||||
import type { Report, ReportStatus } from "./report.service";
|
||||
|
||||
export interface AdminStats {
|
||||
users: number;
|
||||
@@ -11,4 +13,24 @@ export const adminService = {
|
||||
const response = await api.get("/admin/stats");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getReports: async (limit = 10, offset = 0): Promise<Report[]> => {
|
||||
const response = await api.get("/reports", { params: { limit, offset } });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateReportStatus: async (
|
||||
reportId: string,
|
||||
status: ReportStatus,
|
||||
): Promise<void> => {
|
||||
await api.patch(`/reports/${reportId}/status`, { status });
|
||||
},
|
||||
|
||||
deleteUser: async (userId: string): Promise<void> => {
|
||||
await api.delete(`/users/${userId}`);
|
||||
},
|
||||
|
||||
updateUser: async (userId: string, data: Partial<User>): Promise<void> => {
|
||||
await api.patch(`/users/admin/${userId}`, data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import api from "@/lib/api";
|
||||
import type { LoginResponse, RegisterPayload } from "@/types/auth";
|
||||
import type {
|
||||
LoginResponse,
|
||||
RegisterPayload,
|
||||
TwoFactorSetupResponse,
|
||||
} from "@/types/auth";
|
||||
|
||||
export const AuthService = {
|
||||
async login(email: string, password: string): Promise<LoginResponse> {
|
||||
@@ -10,6 +14,14 @@ export const AuthService = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async verify2fa(userId: string, token: string): Promise<LoginResponse> {
|
||||
const { data } = await api.post<LoginResponse>("/auth/verify-2fa", {
|
||||
userId,
|
||||
token,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async register(payload: RegisterPayload): Promise<void> {
|
||||
await api.post("/auth/register", payload);
|
||||
},
|
||||
@@ -21,4 +33,19 @@ export const AuthService = {
|
||||
async refresh(): Promise<void> {
|
||||
await api.post("/auth/refresh");
|
||||
},
|
||||
|
||||
async setup2fa(): Promise<TwoFactorSetupResponse> {
|
||||
const { data } = await api.post<TwoFactorSetupResponse>(
|
||||
"/users/me/2fa/setup",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async enable2fa(token: string): Promise<void> {
|
||||
await api.post("/users/me/2fa/enable", { token });
|
||||
},
|
||||
|
||||
async disable2fa(token: string): Promise<void> {
|
||||
await api.post("/users/me/2fa/disable", { token });
|
||||
},
|
||||
};
|
||||
|
||||
48
frontend/src/services/comment.service.ts
Normal file
48
frontend/src/services/comment.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import api from "@/lib/api";
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
text: string;
|
||||
parentId?: string;
|
||||
likesCount: number;
|
||||
isLiked: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: {
|
||||
uuid: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const CommentService = {
|
||||
async getByContentId(contentId: string): Promise<Comment[]> {
|
||||
const { data } = await api.get<Comment[]>(`/contents/${contentId}/comments`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(
|
||||
contentId: string,
|
||||
text: string,
|
||||
parentId?: string,
|
||||
): Promise<Comment> {
|
||||
const { data } = await api.post<Comment>(`/contents/${contentId}/comments`, {
|
||||
text,
|
||||
parentId,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async remove(commentId: string): Promise<void> {
|
||||
await api.delete(`/comments/${commentId}`);
|
||||
},
|
||||
|
||||
async like(commentId: string): Promise<void> {
|
||||
await api.post(`/comments/${commentId}/like`);
|
||||
},
|
||||
|
||||
async unlike(commentId: string): Promise<void> {
|
||||
await api.delete(`/comments/${commentId}/like`);
|
||||
},
|
||||
};
|
||||
62
frontend/src/services/message.service.ts
Normal file
62
frontend/src/services/message.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import api from "@/lib/api";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
lastMessage?: {
|
||||
text: string;
|
||||
createdAt: string;
|
||||
};
|
||||
recipient: {
|
||||
uuid: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
senderId: string;
|
||||
readAt?: string;
|
||||
}
|
||||
|
||||
export const MessageService = {
|
||||
async getConversations(): Promise<Conversation[]> {
|
||||
const { data } = await api.get<Conversation[]>("/messages/conversations");
|
||||
return data;
|
||||
},
|
||||
|
||||
async getUnreadCount(): Promise<number> {
|
||||
const { data } = await api.get<number>("/messages/unread-count");
|
||||
return data;
|
||||
},
|
||||
|
||||
async getMessages(conversationId: string): Promise<Message[]> {
|
||||
const { data } = await api.get<Message[]>(
|
||||
`/messages/conversations/${conversationId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getConversationWith(userId: string): Promise<Conversation | null> {
|
||||
const { data } = await api.get<Conversation | null>(
|
||||
`/messages/conversations/with/${userId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async sendMessage(recipientId: string, text: string): Promise<Message> {
|
||||
const { data } = await api.post<Message>("/messages", {
|
||||
recipientId,
|
||||
text,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async markAsRead(conversationId: string): Promise<void> {
|
||||
await api.patch(`/messages/conversations/${conversationId}/read`);
|
||||
},
|
||||
};
|
||||
40
frontend/src/services/report.service.ts
Normal file
40
frontend/src/services/report.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import api from "@/lib/api";
|
||||
|
||||
export enum ReportReason {
|
||||
INAPPROPRIATE = "inappropriate",
|
||||
SPAM = "spam",
|
||||
COPYRIGHT = "copyright",
|
||||
OTHER = "other",
|
||||
}
|
||||
|
||||
export enum ReportStatus {
|
||||
PENDING = "pending",
|
||||
REVIEWED = "reviewed",
|
||||
RESOLVED = "resolved",
|
||||
DISMISSED = "dismissed",
|
||||
}
|
||||
|
||||
export interface CreateReportPayload {
|
||||
contentId?: string;
|
||||
tagId?: string;
|
||||
reason: ReportReason;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
uuid: string;
|
||||
reporterId: string;
|
||||
contentId?: string;
|
||||
tagId?: string;
|
||||
reason: ReportReason;
|
||||
description?: string;
|
||||
status: ReportStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const ReportService = {
|
||||
async create(payload: CreateReportPayload): Promise<void> {
|
||||
await api.post("/reports", payload);
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,13 @@ export const UserService = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async search(query: string): Promise<User[]> {
|
||||
const { data } = await api.get<User[]>("/users/search", {
|
||||
params: { q: query },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateMe(update: Partial<User>): Promise<User> {
|
||||
const { data } = await api.patch<User>("/users/me", update);
|
||||
return data;
|
||||
@@ -53,4 +60,9 @@ export const UserService = {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async exportData(): Promise<Record<string, unknown>> {
|
||||
const { data } = await api.get<Record<string, unknown>>("/users/me/export");
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface LoginResponse {
|
||||
message: string;
|
||||
userId: string;
|
||||
userId?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
@@ -17,6 +19,12 @@ export interface AuthStatus {
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
role?: string;
|
||||
};
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupResponse {
|
||||
qrCodeUrl: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Content {
|
||||
description?: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
type: "meme" | "gif";
|
||||
type: "meme" | "gif" | "video";
|
||||
mimeType: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface User {
|
||||
bio?: string;
|
||||
role?: "user" | "admin" | "moderator";
|
||||
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
|
||||
twoFactorEnabled?: boolean;
|
||||
showOnlineStatus?: boolean;
|
||||
showReadReceipts?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@memegoat/source",
|
||||
"version": "1.4.1",
|
||||
"version": "1.9.4",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"version:get": "cmake -P version.cmake GET",
|
||||
|
||||
324
pnpm-lock.yaml
generated
324
pnpm-lock.yaml
generated
@@ -28,19 +28,25 @@ importers:
|
||||
version: 4.0.2(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/mapped-types':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)
|
||||
'@nestjs/platform-socket.io':
|
||||
specifier: ^11.1.12
|
||||
version: 11.1.12(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2)
|
||||
'@nestjs/schedule':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)
|
||||
'@nestjs/throttler':
|
||||
specifier: ^6.5.0
|
||||
version: 6.5.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(reflect-metadata@0.2.2)
|
||||
'@nestjs/websockets':
|
||||
specifier: ^11.1.12
|
||||
version: 11.1.12(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@noble/post-quantum':
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.4
|
||||
@@ -113,6 +119,9 @@ importers:
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
socket.io:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
@@ -156,6 +165,9 @@ importers:
|
||||
'@types/sharp':
|
||||
specifier: ^0.32.0
|
||||
version: 0.32.0
|
||||
'@types/socket.io':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
@@ -388,6 +400,9 @@ importers:
|
||||
recharts:
|
||||
specifier: 2.15.4
|
||||
version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
socket.io-client:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -795,24 +810,28 @@ packages:
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.3.11':
|
||||
resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.3.11':
|
||||
resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.3.11':
|
||||
resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==}
|
||||
@@ -893,24 +912,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@css-inline/css-inline-linux-arm64-musl@0.14.1':
|
||||
resolution: {integrity: sha512-FzknI+st8eA8YQSdEJU9ykcM0LZjjigBuynVF5/p7hiMm9OMP8aNhWbhZ8LKJpKbZrQsxSGS4g9Vnr6n6FiSdQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@css-inline/css-inline-linux-x64-gnu@0.14.1':
|
||||
resolution: {integrity: sha512-yubbEye+daDY/4vXnyASAxH88s256pPati1DfVoZpU1V0+KP0BZ1dByZOU1ktExurbPH3gZOWisAnBE9xon0Uw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@css-inline/css-inline-linux-x64-musl@0.14.1':
|
||||
resolution: {integrity: sha512-6CRAZzoy1dMLPC/tns2rTt1ZwPo0nL/jYBEIAsYTCWhfAnNnpoLKVh5Nm+fSU3OOwTTqU87UkGrFJhObD/wobQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@css-inline/css-inline-win32-x64-msvc@0.14.1':
|
||||
resolution: {integrity: sha512-nzotGiaiuiQW78EzsiwsHZXbxEt6DiMUFcDJ6dhiliomXxnlaPyBfZb6/FMBgRJOf6sknDt/5695OttNmbMYzg==}
|
||||
@@ -1521,89 +1544,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -1996,6 +2035,13 @@ packages:
|
||||
'@nestjs/common': ^11.0.0
|
||||
'@nestjs/core': ^11.0.0
|
||||
|
||||
'@nestjs/platform-socket.io@11.1.12':
|
||||
resolution: {integrity: sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^11.0.0
|
||||
'@nestjs/websockets': ^11.0.0
|
||||
rxjs: ^7.1.0
|
||||
|
||||
'@nestjs/schedule@6.1.0':
|
||||
resolution: {integrity: sha512-W25Ydc933Gzb1/oo7+bWzzDiOissE+h/dhIAPugA39b9MuIzBbLybuXpc1AjoQLczO3v0ldmxaffVl87W0uqoQ==}
|
||||
peerDependencies:
|
||||
@@ -2027,6 +2073,18 @@ packages:
|
||||
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
reflect-metadata: ^0.1.13 || ^0.2.0
|
||||
|
||||
'@nestjs/websockets@11.1.12':
|
||||
resolution: {integrity: sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^11.0.0
|
||||
'@nestjs/core': ^11.0.0
|
||||
'@nestjs/platform-socket.io': ^11.0.0
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
rxjs: ^7.1.0
|
||||
peerDependenciesMeta:
|
||||
'@nestjs/platform-socket.io':
|
||||
optional: true
|
||||
|
||||
'@next/env@16.1.1':
|
||||
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
|
||||
|
||||
@@ -2047,24 +2105,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.1':
|
||||
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.1':
|
||||
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.1':
|
||||
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.1':
|
||||
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
|
||||
@@ -2135,24 +2197,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@node-rs/argon2-linux-arm64-musl@2.0.2':
|
||||
resolution: {integrity: sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@node-rs/argon2-linux-x64-gnu@2.0.2':
|
||||
resolution: {integrity: sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@node-rs/argon2-linux-x64-musl@2.0.2':
|
||||
resolution: {integrity: sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@node-rs/argon2-wasm32-wasi@2.0.2':
|
||||
resolution: {integrity: sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==}
|
||||
@@ -3120,66 +3186,79 @@ packages:
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
@@ -3468,6 +3547,9 @@ packages:
|
||||
resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -3515,24 +3597,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
@@ -3608,6 +3694,9 @@ packages:
|
||||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -3830,6 +3919,10 @@ packages:
|
||||
resolution: {integrity: sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==}
|
||||
deprecated: This is a stub types definition. sharp provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/socket.io@3.0.2':
|
||||
resolution: {integrity: sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ==}
|
||||
deprecated: This is a stub types definition. socket.io provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/stack-utils@2.0.3':
|
||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||
|
||||
@@ -3965,41 +4058,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -4082,6 +4183,10 @@ packages:
|
||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4274,6 +4379,10 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
base64id@2.0.0:
|
||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||
engines: {node: ^4.5.0 || >= 5.9}
|
||||
|
||||
baseline-browser-mapping@2.9.11:
|
||||
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
|
||||
hasBin: true
|
||||
@@ -5135,6 +5244,17 @@ packages:
|
||||
resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
engine.io-client@6.6.4:
|
||||
resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==}
|
||||
|
||||
engine.io-parser@5.2.3:
|
||||
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
engine.io@6.6.5:
|
||||
resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==}
|
||||
engines: {node: '>=10.2.0'}
|
||||
|
||||
enhanced-resolve@5.18.4:
|
||||
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -6267,24 +6387,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
@@ -6789,6 +6913,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
negotiator@0.6.3:
|
||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -6894,6 +7022,10 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7603,6 +7735,21 @@ packages:
|
||||
slick@1.12.2:
|
||||
resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
|
||||
|
||||
socket.io-adapter@2.5.6:
|
||||
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
|
||||
|
||||
socket.io-client@4.8.3:
|
||||
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
socket.io-parser@4.2.5:
|
||||
resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
socket.io@4.8.3:
|
||||
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
||||
engines: {node: '>=10.2.0'}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
@@ -8280,6 +8427,18 @@ packages:
|
||||
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml2js@0.6.2:
|
||||
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -8288,6 +8447,10 @@ packages:
|
||||
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2:
|
||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -9796,7 +9959,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jest/fake-timers': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
jest-mock: 30.2.0
|
||||
|
||||
'@jest/expect-utils@30.2.0':
|
||||
@@ -9814,7 +9977,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jest/types': 30.2.0
|
||||
'@sinonjs/fake-timers': 13.0.5
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
jest-message-util: 30.2.0
|
||||
jest-mock: 30.2.0
|
||||
jest-util: 30.2.0
|
||||
@@ -10002,7 +10165,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@css-inline/css-inline': 0.14.1
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
glob: 10.3.12
|
||||
nodemailer: 7.0.12
|
||||
optionalDependencies:
|
||||
@@ -10021,7 +10184,7 @@ snapshots:
|
||||
'@nestjs/cache-manager@3.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(cache-manager@7.2.7)(keyv@5.5.5)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
cache-manager: 7.2.7
|
||||
keyv: 5.5.5
|
||||
rxjs: 7.8.2
|
||||
@@ -10075,7 +10238,7 @@ snapshots:
|
||||
lodash: 4.17.21
|
||||
rxjs: 7.8.2
|
||||
|
||||
'@nestjs/core@11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
'@nestjs/core@11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nuxt/opencollective': 0.4.1
|
||||
@@ -10088,6 +10251,7 @@ snapshots:
|
||||
uid: 2.0.2
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)
|
||||
'@nestjs/websockets': 11.1.12(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
|
||||
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
@@ -10100,7 +10264,7 @@ snapshots:
|
||||
'@nestjs/platform-express@11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
cors: 2.8.5
|
||||
express: 5.2.1
|
||||
multer: 2.0.2
|
||||
@@ -10109,10 +10273,22 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/platform-socket.io@11.1.12(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/websockets': 11.1.12(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
rxjs: 7.8.2
|
||||
socket.io: 4.8.3
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@nestjs/schedule@6.1.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
cron: 4.3.5
|
||||
|
||||
'@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)':
|
||||
@@ -10129,7 +10305,7 @@ snapshots:
|
||||
'@nestjs/testing@11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-express@11.1.11)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)
|
||||
@@ -10137,9 +10313,21 @@ snapshots:
|
||||
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
|
||||
'@nestjs/websockets@11.1.12(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-socket.io@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
iterare: 1.2.1
|
||||
object-hash: 3.0.0
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-socket.io': 11.1.12(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2)
|
||||
|
||||
'@next/env@16.1.1': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.1.1':
|
||||
@@ -11319,7 +11507,7 @@ snapshots:
|
||||
'@sentry/nestjs@10.32.1(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.11)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
|
||||
@@ -11734,6 +11922,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
@@ -11865,6 +12055,10 @@ snapshots:
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 24.10.4
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
@@ -12073,7 +12267,7 @@ snapshots:
|
||||
|
||||
'@types/mysql@2.15.27':
|
||||
dependencies:
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
|
||||
'@types/node@20.19.27':
|
||||
dependencies:
|
||||
@@ -12100,7 +12294,7 @@ snapshots:
|
||||
|
||||
'@types/pg@8.15.6':
|
||||
dependencies:
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
pg-protocol: 1.10.3
|
||||
pg-types: 2.2.0
|
||||
|
||||
@@ -12142,6 +12336,14 @@ snapshots:
|
||||
dependencies:
|
||||
sharp: 0.34.5
|
||||
|
||||
'@types/socket.io@3.0.2':
|
||||
dependencies:
|
||||
socket.io: 4.8.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@types/stack-utils@2.0.3': {}
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
@@ -12158,7 +12360,7 @@ snapshots:
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
dependencies:
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
@@ -12424,6 +12626,11 @@ snapshots:
|
||||
abbrev@2.0.0:
|
||||
optional: true
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -12620,6 +12827,8 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
base64id@2.0.0: {}
|
||||
|
||||
baseline-browser-mapping@2.9.11: {}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
@@ -13450,6 +13659,36 @@ snapshots:
|
||||
encoding-japanese@2.2.0:
|
||||
optional: true
|
||||
|
||||
engine.io-client@6.6.4:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.18.3
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
engine.io-parser@5.2.3: {}
|
||||
|
||||
engine.io@6.6.5:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 24.10.4
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
cors: 2.8.5
|
||||
debug: 4.4.3
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
enhanced-resolve@5.18.4:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -14543,7 +14782,7 @@ snapshots:
|
||||
'@jest/expect': 30.2.0
|
||||
'@jest/test-result': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
chalk: 4.1.2
|
||||
co: 4.6.0
|
||||
dedent: 1.7.1
|
||||
@@ -14640,7 +14879,7 @@ snapshots:
|
||||
'@jest/environment': 30.2.0
|
||||
'@jest/fake-timers': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
jest-mock: 30.2.0
|
||||
jest-util: 30.2.0
|
||||
jest-validate: 30.2.0
|
||||
@@ -14825,13 +15064,13 @@ snapshots:
|
||||
|
||||
jest-worker@27.5.1:
|
||||
dependencies:
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
jest-worker@30.2.0:
|
||||
dependencies:
|
||||
'@types/node': 22.19.6
|
||||
'@types/node': 24.10.4
|
||||
'@ungap/structured-clone': 1.3.0
|
||||
jest-util: 30.2.0
|
||||
merge-stream: 2.0.0
|
||||
@@ -16017,6 +16256,8 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@0.6.3: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
@@ -16112,6 +16353,8 @@ snapshots:
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@3.0.0: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
@@ -17049,6 +17292,47 @@ snapshots:
|
||||
slick@1.12.2:
|
||||
optional: true
|
||||
|
||||
socket.io-adapter@2.5.6:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
socket.io-client@4.8.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3
|
||||
engine.io-client: 6.6.4
|
||||
socket.io-parser: 4.2.5
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
socket.io-parser@4.2.5:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
socket.io@4.8.3:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cors: 2.8.5
|
||||
debug: 4.4.3
|
||||
engine.io: 6.6.5
|
||||
socket.io-adapter: 2.5.6
|
||||
socket.io-parser: 4.2.5
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
@@ -17751,6 +18035,8 @@ snapshots:
|
||||
imurmurhash: 0.1.4
|
||||
signal-exit: 4.1.0
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xml2js@0.6.2:
|
||||
dependencies:
|
||||
sax: 1.4.3
|
||||
@@ -17758,6 +18044,8 @@ snapshots:
|
||||
|
||||
xmlbuilder@11.0.1: {}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
Reference in New Issue
Block a user