14 Commits

Author SHA1 Message Date
Mathis HERRIOT
7615ec670e chore: bump version to 1.9.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m58s
2026-01-29 17:44:55 +01:00
Mathis HERRIOT
40cfff683d fix: ensure decrypted PGP values are cast to text in SQL queries
- Added `::text` cast to `pgp_sym_decrypt` function calls for consistent data type handling.
2026-01-29 17:44:50 +01:00
Mathis HERRIOT
bb52782226 feat: enhance environment configuration and CORS handling
- Added `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_CONTACT_EMAIL` to environment variables for frontend configuration.
- Updated CORS logic to support domain-based restrictions with dynamic origin validation.
- Improved frontend image hostname resolution using environment-driven URLs.
- Standardized contact email usage across the application.
2026-01-29 17:34:53 +01:00
Mathis HERRIOT
6a70274623 fix: handle null enriched comment in comments service
- Added a null check for `enrichedComment` to prevent processing invalid data and potential runtime errors.
2026-01-29 17:22:00 +01:00
Mathis HERRIOT
aabc615b89 feat: enhance CORS and user connection handling in WebSocket gateway
- Improved CORS configuration to allow specific origins for development and mobile use cases.
- Added validation for token payload to ensure `sub` property is present.
- Enhanced user connection management by using `userId` consistently for online status tracking and room joining.
2026-01-29 17:21:53 +01:00
Mathis HERRIOT
f9b202375f feat: improve accessibility, security & user interaction in notifications and setup
- Replaced `div` with `button` elements in `NotificationHandler` for better semantics and accessibility.
- Added conditional QR Code reveal in 2FA setup with `blur` effect for enhanced security and user control.
- Enhanced messages layout for responsiveness on smaller screens with dynamic chat/sidebar toggling.
- Simplified legacy prop handling in `ShareDialog`.
2026-01-29 17:21:19 +01:00
Mathis HERRIOT
6398965f16 chore: bump version to 1.9.0
Some checks failed
CI/CD Pipeline / Valider backend (push) Failing after 1m13s
CI/CD Pipeline / Valider frontend (push) Failing after 1m18s
CI/CD Pipeline / Valider documentation (push) Successful in 1m44s
CI/CD Pipeline / Déploiement en Production (push) Has been skipped
2026-01-29 17:06:14 +01:00
Mathis HERRIOT
9e9b1db012 feat: manage user online status and typing indicator in socket gateway
- Added tracking of online users with real-time status updates (online/offline).
- Implemented `handleTyping` to broadcast user typing events to recipients.
- Added `check_status` handler to query user online status.
- Enhanced CORS configuration to support multi-domain deployments with credentials.
2026-01-29 16:56:36 +01:00
Mathis HERRIOT
62bf03d07a feat: implement NotificationHandler component for real-time notifications
- Added `NotificationHandler` component for managing real-time notifications using sockets.
- Display notifications for comments, replies, likes, and messages with interactive toasts.
- Integrated click handling for navigation to relevant pages based on notification type.
2026-01-29 16:56:19 +01:00
Mathis HERRIOT
c83ba6eb7d feat: add NotificationHandler component to layout
- Integrated `NotificationHandler` into the app layout for centralized notification management.
- Ensured compatibility with existing `Toaster` component for consistent user feedback.
2026-01-29 16:51:20 +01:00
Mathis HERRIOT
05a05a1940 feat: add share dialog and typing indicator in messages
- Implemented `ShareDialog` component for sharing content directly with other users.
- Added typing indicator when a user is composing a message in an active conversation.
- Updated `SocketProvider` to handle improved connection management and user status updates.
- Enhanced the messages UI with real-time online status and typing indicators for better feedback.
2026-01-29 16:50:53 +01:00
Mathis HERRIOT
7c065a2fb9 feat: inject ContentsRepository into CommentsService for better integration
- Added `ContentsRepository` as a dependency to `CommentsService` and updated tests for mock setup.
- Adjusted import order in relevant files to align with project standards.
2026-01-29 16:49:13 +01:00
Mathis HERRIOT
001cdaff8f feat: add notification system for comments and likes
- Notify post authors when their content receives a new comment.
- Notify parent comment authors when their comment receives a reply.
- Send notifications to comment authors when their comments are liked.
- Handle notification errors gracefully with error
2026-01-29 16:41:13 +01:00
Mathis HERRIOT
0eb940c5ce feat: add ContentsModule to CommentsModule with forward reference
- Updated imports in `comments.module.ts` to include `ContentsModule` using `forwardRef` for dependency resolution.
2026-01-29 16:22:55 +01:00
18 changed files with 649 additions and 38 deletions

View File

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

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module"; import { AuthModule } from "../auth/auth.module";
import { ContentsModule } from "../contents/contents.module";
import { RealtimeModule } from "../realtime/realtime.module"; import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module"; import { S3Module } from "../s3/s3.module";
import { CommentsController } from "./comments.controller"; import { CommentsController } from "./comments.controller";
@@ -8,7 +9,12 @@ import { CommentLikesRepository } from "./repositories/comment-likes.repository"
import { CommentsRepository } from "./repositories/comments.repository"; import { CommentsRepository } from "./repositories/comments.repository";
@Module({ @Module({
imports: [AuthModule, S3Module, RealtimeModule], imports: [
AuthModule,
S3Module,
RealtimeModule,
forwardRef(() => ContentsModule),
],
controllers: [CommentsController], controllers: [CommentsController],
providers: [CommentsService, CommentsRepository, CommentLikesRepository], providers: [CommentsService, CommentsRepository, CommentLikesRepository],
exports: [CommentsService], exports: [CommentsService],

View File

@@ -1,5 +1,6 @@
import { ForbiddenException, NotFoundException } from "@nestjs/common"; import { ForbiddenException, NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway"; import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import { CommentsService } from "./comments.service"; import { CommentsService } from "./comments.service";
@@ -25,12 +26,17 @@ describe("CommentsService", () => {
isLikedByUser: jest.fn(), isLikedByUser: jest.fn(),
}; };
const mockContentsRepository = {
findOne: jest.fn(),
};
const mockS3Service = { const mockS3Service = {
getPublicUrl: jest.fn(), getPublicUrl: jest.fn(),
}; };
const mockEventsGateway = { const mockEventsGateway = {
sendToContent: jest.fn(), sendToContent: jest.fn(),
sendToUser: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -40,6 +46,7 @@ describe("CommentsService", () => {
CommentsService, CommentsService,
{ provide: CommentsRepository, useValue: mockCommentsRepository }, { provide: CommentsRepository, useValue: mockCommentsRepository },
{ provide: CommentLikesRepository, useValue: mockCommentLikesRepository }, { provide: CommentLikesRepository, useValue: mockCommentLikesRepository },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: S3Service, useValue: mockS3Service }, { provide: S3Service, useValue: mockS3Service },
{ provide: EventsGateway, useValue: mockEventsGateway }, { provide: EventsGateway, useValue: mockEventsGateway },
], ],

View File

@@ -1,8 +1,11 @@
import { import {
ForbiddenException, ForbiddenException,
forwardRef,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ContentsRepository } from "../contents/repositories/contents.repository";
import { EventsGateway } from "../realtime/events.gateway"; import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service"; import { S3Service } from "../s3/s3.service";
import type { CreateCommentDto } from "./dto/create-comment.dto"; import type { CreateCommentDto } from "./dto/create-comment.dto";
@@ -14,6 +17,8 @@ export class CommentsService {
constructor( constructor(
private readonly commentsRepository: CommentsRepository, private readonly commentsRepository: CommentsRepository,
private readonly commentLikesRepository: CommentLikesRepository, private readonly commentLikesRepository: CommentLikesRepository,
@Inject(forwardRef(() => ContentsRepository))
private readonly contentsRepository: ContentsRepository,
private readonly s3Service: S3Service, private readonly s3Service: S3Service,
private readonly eventsGateway: EventsGateway, private readonly eventsGateway: EventsGateway,
) {} ) {}
@@ -28,10 +33,48 @@ export class CommentsService {
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket // Récupérer le commentaire avec les infos utilisateur pour le WebSocket
const enrichedComment = await this.findOneEnriched(comment.id, userId); const enrichedComment = await this.findOneEnriched(comment.id, userId);
if (!enrichedComment) return null;
// Notifier les autres utilisateurs sur ce contenu // Notifier les autres utilisateurs sur ce contenu (room de contenu)
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment); 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; return enrichedComment;
} }
@@ -105,6 +148,23 @@ export class CommentsService {
throw new NotFoundException("Comment not found"); throw new NotFoundException("Comment not found");
} }
await this.commentLikesRepository.addLike(commentId, userId); 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) { async unlike(userId: string, commentId: string) {

View File

@@ -41,7 +41,9 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T { export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
const originalGetSQL = column.getSQL.bind(column); const originalGetSQL = column.getSQL.bind(column);
column.getSQL = () => column.getSQL = () =>
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column); sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
column,
);
return column; return column;
} }
@@ -59,5 +61,7 @@ export function pgpSymDecrypt(
column: AnyPgColumn, column: AnyPgColumn,
key: string | SQL, key: string | SQL,
): SQL<string> { ): 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>;
} }

View File

@@ -16,9 +16,39 @@ import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service"; import { JwtService } from "../crypto/services/jwt.service";
@WebSocketGateway({ @WebSocketGateway({
transports: ["websocket"],
cors: { cors: {
origin: "*", 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, credentials: true,
methods: ["GET", "POST"],
}, },
}) })
export class EventsGateway export class EventsGateway
@@ -28,6 +58,7 @@ export class EventsGateway
server!: Server; server!: Server;
private readonly logger = new Logger(EventsGateway.name); private readonly logger = new Logger(EventsGateway.name);
private readonly onlineUsers = new Map<string, Set<string>>(); // userId -> Set of socketIds
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
@@ -64,11 +95,23 @@ export class EventsGateway
} }
const payload = await this.jwtService.verifyJwt(session.accessToken); const payload = await this.jwtService.verifyJwt(session.accessToken);
if (!payload.sub) {
throw new Error("Invalid token payload: missing sub");
}
client.data.user = payload; client.data.user = payload;
// Rejoindre une room personnelle pour les notifications // Rejoindre une room personnelle pour les notifications
client.join(`user:${payload.sub}`); 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());
this.server.emit("user_status", { userId, status: "online" });
}
this.onlineUsers.get(userId)?.add(client.id);
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`); this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
} catch (error) { } catch (error) {
this.logger.error(`Connection error for client ${client.id}: ${error}`); this.logger.error(`Connection error for client ${client.id}: ${error}`);
@@ -77,6 +120,15 @@ export class EventsGateway
} }
handleDisconnect(client: Socket) { 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);
this.server.emit("user_status", { userId, status: "offline" });
}
}
this.logger.log(`Client disconnected: ${client.id}`); this.logger.log(`Client disconnected: ${client.id}`);
} }
@@ -98,6 +150,31 @@ export class EventsGateway
this.logger.log(`Client ${client.id} left content room: ${contentId}`); this.logger.log(`Client ${client.id} left content room: ${contentId}`);
} }
@SubscribeMessage("typing")
handleTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { recipientId: string; isTyping: boolean },
) {
const userId = client.data.user?.sub;
if (!userId) return;
this.server.to(`user:${data.recipientId}`).emit("user_typing", {
userId,
isTyping: data.isTyping,
});
}
@SubscribeMessage("check_status")
handleCheckStatus(
@ConnectedSocket() _client: Socket,
@MessageBody() userId: string,
) {
return {
userId,
status: this.onlineUsers.has(userId) ? "online" : "offline",
};
}
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique // Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
sendToUser(userId: string, event: string, data: any) { sendToUser(userId: string, event: string, data: any) {
this.server.to(`user:${userId}`).emit(event, data); this.server.to(`user:${userId}`).emit(event, data);

View File

@@ -131,6 +131,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr} 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: depends_on:
- backend - backend

View File

@@ -1,5 +1,16 @@
import type { NextConfig } from "next"; 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 = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
@@ -7,11 +18,11 @@ const nextConfig: NextConfig = {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "memegoat.fr", hostname: getHostname(appUrl),
}, },
{ {
protocol: "https", protocol: "https",
hostname: "api.memegoat.fr", hostname: getHostname(apiUrl),
}, },
], ],
}, },

View File

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

View File

@@ -63,7 +63,9 @@ export default function HelpPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p> </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>
</div> </div>
); );

View File

@@ -2,7 +2,7 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Search, Send, UserPlus, X } from "lucide-react"; import { ArrowLeft, Search, Send, UserPlus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
@@ -32,8 +32,29 @@ export default function MessagesPage() {
const [activeConv, setActiveConv] = React.useState<Conversation | null>(null); const [activeConv, setActiveConv] = React.useState<Conversation | null>(null);
const [messages, setMessages] = React.useState<Message[]>([]); const [messages, setMessages] = React.useState<Message[]>([]);
const [newMessage, setNewMessage] = React.useState(""); 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 [isLoadingConvs, setIsLoadingConvs] = React.useState(true);
const [isLoadingMsgs, setIsLoadingMsgs] = React.useState(false); 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 [searchQuery, setSearchQuery] = React.useState("");
const [searchResults, setSearchResults] = React.useState<User[]>([]); const [searchResults, setSearchResults] = React.useState<User[]>([]);
@@ -120,6 +141,7 @@ export default function MessagesPage() {
(data: { conversationId: string; message: Message }) => { (data: { conversationId: string; message: Message }) => {
if (activeConv?.id === data.conversationId) { if (activeConv?.id === data.conversationId) {
setMessages((prev) => [...prev, data.message]); setMessages((prev) => [...prev, data.message]);
setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus
} }
// Mettre à jour la liste des conversations // Mettre à jour la liste des conversations
setConversations((prev) => { setConversations((prev) => {
@@ -144,8 +166,28 @@ export default function MessagesPage() {
}, },
); );
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);
}
});
return () => { return () => {
socket.off("new_message"); socket.off("new_message");
socket.off("user_status");
socket.off("user_typing");
}; };
} }
}, [socket, activeConv]); }, [socket, activeConv]);
@@ -196,7 +238,11 @@ export default function MessagesPage() {
return ( return (
<div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950"> <div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950">
{/* Sidebar - Liste des conversations */} {/* Sidebar - Liste des conversations */}
<div className="w-80 border-r flex flex-col"> <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="p-4 border-b">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Messages</h2> <h2 className="text-xl font-bold">Messages</h2>
@@ -336,14 +382,26 @@ export default function MessagesPage() {
</div> </div>
{/* Zone de chat */} {/* Zone de chat */}
<div className="flex-1 flex flex-col"> <div
className={`flex-1 flex flex-col ${
!activeConv ? "hidden md:flex" : "flex"
}`}
>
{activeConv ? ( {activeConv ? (
<> <>
{/* Header */} {/* Header */}
<div className="p-4 border-b flex items-center justify-between"> <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 <Link
href={`/user/${activeConv.recipient.username}`} href={`/user/${activeConv.recipient.username}`}
className="flex items-center gap-3 hover:opacity-80 transition-opacity" className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity"
> >
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={activeConv.recipient.avatarUrl} /> <AvatarImage src={activeConv.recipient.avatarUrl} />
@@ -355,7 +413,17 @@ export default function MessagesPage() {
<h3 className="font-bold leading-none"> <h3 className="font-bold leading-none">
{activeConv.recipient.displayName || activeConv.recipient.username} {activeConv.recipient.displayName || activeConv.recipient.username}
</h3> </h3>
<span className="text-xs text-green-500 font-medium">En ligne</span> <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> </div>
</Link> </Link>
</div> </div>
@@ -406,6 +474,17 @@ export default function MessagesPage() {
</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> </div>
</ScrollArea> </ScrollArea>
@@ -415,7 +494,10 @@ export default function MessagesPage() {
<Input <Input
placeholder="Écrivez un message..." placeholder="Écrivez un message..."
value={newMessage} value={newMessage}
onChange={(e) => setNewMessage(e.target.value)} onChange={(e) => {
setNewMessage(e.target.value);
handleTyping();
}}
className="rounded-full px-4" className="rounded-full px-4"
/> />
<Button <Button

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google"; import { Ubuntu_Mono, Ubuntu_Sans } from "next/font/google";
import { NotificationHandler } from "@/components/notification-handler";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { AudioProvider } from "@/providers/audio-provider"; import { AudioProvider } from "@/providers/audio-provider";
import { AuthProvider } from "@/providers/auth-provider"; import { AuthProvider } from "@/providers/auth-provider";
@@ -31,7 +32,7 @@ export const metadata: Metadata = {
openGraph: { openGraph: {
type: "website", type: "website",
locale: "fr_FR", locale: "fr_FR",
url: "https://memegoat.local", url: "/",
siteName: "MemeGoat", siteName: "MemeGoat",
title: "MemeGoat | Partagez vos meilleurs mèmes", title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !", description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",
@@ -76,6 +77,7 @@ export default function RootLayout({
<SocketProvider> <SocketProvider>
<AudioProvider> <AudioProvider>
{children} {children}
<NotificationHandler />
<Toaster /> <Toaster />
</AudioProvider> </AudioProvider>
</SocketProvider> </SocketProvider>

View File

@@ -35,6 +35,7 @@ import { useAuth } from "@/providers/auth-provider";
import { ContentService } from "@/services/content.service"; import { ContentService } from "@/services/content.service";
import { FavoriteService } from "@/services/favorite.service"; import { FavoriteService } from "@/services/favorite.service";
import type { Content } from "@/types/content"; import type { Content } from "@/types/content";
import { ShareDialog } from "./share-dialog";
import { UserContentEditDialog } from "./user-content-edit-dialog"; import { UserContentEditDialog } from "./user-content-edit-dialog";
import { ViewCounter } from "./view-counter"; import { ViewCounter } from "./view-counter";
@@ -51,6 +52,7 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
const [isLiked, setIsLiked] = React.useState(content.isLiked || false); const [isLiked, setIsLiked] = React.useState(content.isLiked || false);
const [likesCount, setLikesCount] = React.useState(content.favoritesCount); const [likesCount, setLikesCount] = React.useState(content.favoritesCount);
const [editDialogOpen, setEditDialogOpen] = React.useState(false); const [editDialogOpen, setEditDialogOpen] = React.useState(false);
const [shareDialogOpen, setShareDialogOpen] = React.useState(false);
const [_reportDialogOpen, setReportDialogOpen] = React.useState(false); const [_reportDialogOpen, setReportDialogOpen] = React.useState(false);
const isAuthor = user?.uuid === content.authorId; const isAuthor = user?.uuid === content.authorId;
@@ -190,7 +192,15 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
</DropdownMenuItem> </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" /> <Share2 className="h-4 w-4 mr-2" />
Partager Partager
</DropdownMenuItem> </DropdownMenuItem>
@@ -263,10 +273,11 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
navigator.clipboard.writeText( if (!isAuthenticated) {
`${window.location.origin}/meme/${content.slug}`, toast.error("Connectez-vous pour partager");
); return;
toast.success("Lien copié !"); }
setShareDialogOpen(true);
}} }}
className="hover:text-muted-foreground" className="hover:text-muted-foreground"
> >
@@ -322,6 +333,13 @@ export function ContentCard({ content, onUpdate }: ContentCardProps) {
onOpenChange={setEditDialogOpen} onOpenChange={setEditDialogOpen}
onSuccess={() => onUpdate?.()} onSuccess={() => onUpdate?.()}
/> />
<ShareDialog
contentId={content.id}
contentTitle={content.title}
contentUrl={`${typeof window !== "undefined" ? window.location.origin : ""}/meme/${content.slug}`}
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
/>
</> </>
); );
} }

View 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;
}

View 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>
);
}

View File

@@ -29,6 +29,7 @@ export function TwoFactorSetup() {
const [secret, setSecret] = useState<string | null>(null); const [secret, setSecret] = useState<string | null>(null);
const [otpValue, setOtpValue] = useState(""); const [otpValue, setOtpValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isRevealed, setIsRevealed] = useState(false);
const handleSetup = async () => { const handleSetup = async () => {
setIsLoading(true); setIsLoading(true);
@@ -152,24 +153,59 @@ export function TwoFactorSetup() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center gap-6"> <CardContent className="flex flex-col items-center gap-6">
{qrCode && ( {qrCode && (
<div className="bg-white p-4 rounded-xl border-4 border-zinc-100"> <div className="relative group">
<Image <div
src={qrCode} className={`bg-white p-4 rounded-xl border-4 border-zinc-100 transition-all duration-300 ${
alt="QR Code 2FA" !isRevealed ? "blur-md select-none" : ""
width={192} }`}
height={192} >
className="w-48 h-48" <Image
unoptimized 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>
)} )}
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<p className="text-sm font-medium text-center"> <p className="text-sm font-medium text-center">
Ou entrez ce code manuellement : Ou entrez ce code manuellement :
</p> </p>
<code className="block p-2 bg-muted text-center rounded text-xs font-mono break-all"> <div className="relative group">
{secret} <code
</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>
<div className="flex flex-col items-center gap-4 w-full border-t pt-6"> <div className="flex flex-col items-center gap-4 w-full border-t pt-6">
<p className="text-sm font-medium"> <p className="text-sm font-medium">

View File

@@ -24,15 +24,25 @@ export function SocketProvider({ children }: { children: React.ReactNode }) {
React.useEffect(() => { React.useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; 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, { const socketInstance = io(apiUrl, {
withCredentials: true, withCredentials: true,
transports: ["websocket"], transports: ["websocket"], // Recommandé pour éviter les problèmes de sticky sessions
reconnectionAttempts: 5,
reconnectionDelay: 1000,
}); });
socketInstance.on("connect", () => { socketInstance.on("connect", () => {
console.log("WebSocket connected to:", apiUrl);
setIsConnected(true); 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", () => { socketInstance.on("disconnect", () => {
setIsConnected(false); setIsConnected(false);
}); });

View File

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