commit
ad28aa3b28
73 changed files with 3524 additions and 2804 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,11 +1,15 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
|
||||||
|
/logs
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
.vercel
|
.vercel
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
13
Dockerfile
Normal file
13
Dockerfile
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Build stage
|
||||||
|
FROM node:18.18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
RUN chmod +x /app/docker_entrypoint.sh
|
||||||
|
ENTRYPOINT ["/app/docker_entrypoint.sh"]
|
92
README.md
92
README.md
|
@ -1,53 +1,77 @@
|
||||||
## Presentation
|
<h1 align="center" style="font-weight: bold;">Projet Web - M1 ISA 💻</h1>
|
||||||
|
|
||||||
Creation of a project that use two or more NoSQL technologie: Redis & MangoDB
|
<p align="center">
|
||||||
|
<a href="#tech">Technologies</a> - <a href="#started">Lancer le projet</a> - <a href="#auth">Auteurs</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Front: JavaScript
|
<p align="center">Projet réalisé dans le cadre du cours de <strong>Web languages for data storage and management</strong>.</p>
|
||||||
|
|
||||||
Back : Java (SpringBoot)
|
<h2 id="technologies">💻 Technologies</h2>
|
||||||
|
|
||||||
Api to Server Mongo (Nabil's Server)
|
- SvelteKit
|
||||||
|
- MongoDB
|
||||||
|
- Redis
|
||||||
|
- Docker
|
||||||
|
|
||||||
Update Redis Database
|
<h2 id="started">🚀 Lancer le projet</h2>
|
||||||
|
|
||||||
|
Afin de lancer le projet en local, vous aurez besoin de suivre les instructions suivantes
|
||||||
|
|
||||||
|
<h3>Clonez le dépôt</h3>
|
||||||
|
Commencez par cloner le dépôt Git sur votre machine
|
||||||
# sv
|
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# create a new project in the current directory
|
git clone https://github.com/NabilOuldHamou/M1_ProjetWeb
|
||||||
npx sv create
|
|
||||||
|
|
||||||
# create a new project in my-app
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
<h3>Production</h3>
|
||||||
|
Installez **Docker** sur votre machine puis utilisez la commande suivante pour lancer le projet
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
docker compose up
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
<h3>Développement</h3>
|
||||||
|
TODO
|
||||||
|
|
||||||
To create a production version of your app:
|
<h2 id="auth">🤝 Auteurs</h2>
|
||||||
|
|
||||||
```bash
|
<table>
|
||||||
npm run build
|
<tr>
|
||||||
```
|
<td align="center">
|
||||||
|
<a href="https://github.com/NabilOuldHamou">
|
||||||
|
<img src="https://github.com/NabilOuldHamou.png" width="100px;" alt="Nabil Profile Picture"/><br>
|
||||||
|
<sub>
|
||||||
|
<b>Nabil Ould Hamou</b>
|
||||||
|
</sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
<td align="center">
|
||||||
|
<a href="https://github.com/Luxray555">
|
||||||
|
<img src="https://github.com/Luxray555.png" width="100px;" alt="Bilal Profile Picture"/><br>
|
||||||
|
<sub>
|
||||||
|
<b>Bilal Dieumegard</b>
|
||||||
|
</sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
<td align="center">
|
||||||
|
<a href="https://github.com/YacineHB">
|
||||||
|
<img src="https://github.com/YacineHB.png" width="100px;" alt="Yacine Profile Picture"/><br>
|
||||||
|
<sub>
|
||||||
|
<b>Yacine Hbada</b>
|
||||||
|
</sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Yanax373">
|
||||||
|
<img src="https://github.com/Yanax373.png" width="100px;" alt="Yanis Profile Picture"/><br>
|
||||||
|
<sub>
|
||||||
|
<b>Yanis Bouarfa</b>
|
||||||
|
</sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
14
components.json
Normal file
14
components.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "slate"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils"
|
||||||
|
},
|
||||||
|
"typescript": true
|
||||||
|
}
|
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
services:
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
hostname: app
|
||||||
|
depends_on:
|
||||||
|
- mongodb
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=mongodb://temp-root-username:temp-password@mongodb/chat_projetweb?authSource=admin
|
||||||
|
- JWT_SECRET=ba63466f102443f4bb6f3670891358bc4488d0c717f6ebcd3ee3c5144e55fe2d
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
networks:
|
||||||
|
- app_network
|
||||||
|
volumes:
|
||||||
|
- .:/usr/src/app
|
||||||
|
- /usr/src/app/node_modules
|
||||||
|
|
||||||
|
mongodb:
|
||||||
|
build: ./mongodb_rs
|
||||||
|
hostname: mongodb
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- MONGO_INITDB_ROOT_USERNAME=temp-root-username
|
||||||
|
- MONGO_INITDB_ROOT_PASSWORD=temp-password
|
||||||
|
- MONGO_INITDB_DATABASE=chat_projetweb
|
||||||
|
- MONGO_REPLICA_HOST=mongodb
|
||||||
|
- MONGO_REPLICA_PORT=27017
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db/
|
||||||
|
- mongo-logs:/var/log/mongodb/
|
||||||
|
networks:
|
||||||
|
- app_network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
hostname: redis-server
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: redis-server --save 20 1 --loglevel warning
|
||||||
|
environment:
|
||||||
|
- REDIS_PASSWORD=temp-redis-password
|
||||||
|
volumes:
|
||||||
|
- redis-data:/root/redis
|
||||||
|
networks:
|
||||||
|
- app_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo-data:
|
||||||
|
mongo-logs:
|
||||||
|
redis-data:
|
13
docker_entrypoint.sh
Normal file
13
docker_entrypoint.sh
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -xe
|
||||||
|
|
||||||
|
pnpm install
|
||||||
|
pnpm prisma generate
|
||||||
|
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
pnpx prisma db push
|
||||||
|
|
||||||
|
ls
|
||||||
|
|
||||||
|
node build
|
11
mongodb_rs/Dockerfile
Normal file
11
mongodb_rs/Dockerfile
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
FROM mongo:5
|
||||||
|
|
||||||
|
# we take over the default & start mongo in replica set mode in a background task
|
||||||
|
ENTRYPOINT mongod --port $MONGO_REPLICA_PORT --replSet rs0 --bind_ip 0.0.0.0 & MONGOD_PID=$!; \
|
||||||
|
# we prepare the replica set with a single node and prepare the root user config
|
||||||
|
INIT_REPL_CMD="rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: '$MONGO_REPLICA_HOST:$MONGO_REPLICA_PORT' }] })"; \
|
||||||
|
INIT_USER_CMD="db.getUser('$MONGO_INITDB_ROOT_USERNAME') || db.createUser({ user: '$MONGO_INITDB_ROOT_USERNAME', pwd: '$MONGO_INITDB_ROOT_PASSWORD', roles: ['root'] })"; \
|
||||||
|
# we wait for the replica set to be ready and then submit the command just above
|
||||||
|
until (mongo admin --port $MONGO_REPLICA_PORT --eval "$INIT_REPL_CMD && $INIT_USER_CMD"); do sleep 1; done; \
|
||||||
|
# we are done but we keep the container by waiting on signals from the mongo task
|
||||||
|
echo "REPLICA SET ONLINE"; wait $MONGOD_PID;
|
24
package.json
24
package.json
|
@ -16,6 +16,8 @@
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"bits-ui": "^0.21.16",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.7.0",
|
"eslint": "^9.7.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.36.0",
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
|
@ -26,9 +28,27 @@
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"tailwind-variants": "^0.3.0",
|
||||||
"tailwindcss": "^3.4.9",
|
"tailwindcss": "^3.4.9",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0"
|
||||||
"vite": "^5.0.3"
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.2.10",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"argon2": "^0.41.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lucide-svelte": "^0.462.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"redis": "^4.7.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"svelte-radix": "^2.0.1",
|
||||||
|
"vite": "^5.0.3",
|
||||||
|
"winston": "^3.17.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2750
pnpm-lock.yaml
generated
2750
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
49
prisma/schema.prisma
Normal file
49
prisma/schema.prisma
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||||
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["debian-openssl-1.1.x", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mongodb"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
username String @unique
|
||||||
|
profilePicture String @default("default.png")
|
||||||
|
surname String
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
messages Message[]
|
||||||
|
|
||||||
|
@@map("users") // Table name in DB
|
||||||
|
}
|
||||||
|
|
||||||
|
model Channel {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
name String @unique
|
||||||
|
messages Message[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@map("channels") // Table name in DB
|
||||||
|
}
|
||||||
|
|
||||||
|
model Message {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId String @db.ObjectId
|
||||||
|
channel Channel @relation(fields: [channelId], references: [id])
|
||||||
|
channelId String @db.ObjectId
|
||||||
|
text String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@map("messages") // Table name in DB
|
||||||
|
}
|
81
src/app.css
81
src/app.css
|
@ -1,3 +1,78 @@
|
||||||
@import 'tailwindcss/base';
|
@tailwind base;
|
||||||
@import 'tailwindcss/components';
|
@tailwind components;
|
||||||
@import 'tailwindcss/utilities';
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 72.2% 50.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
5
src/app.d.ts
vendored
5
src/app.d.ts
vendored
|
@ -3,7 +3,10 @@
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
interface Locals {
|
||||||
|
token?: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|
8
src/hooks.server.ts
Normal file
8
src/hooks.server.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
event.locals.token = await event.cookies.get('token');
|
||||||
|
event.locals.userId = await event.cookies.get('UID');
|
||||||
|
|
||||||
|
return await resolve(event);
|
||||||
|
};
|
122
src/lib/components/Message.svelte
Normal file
122
src/lib/components/Message.svelte
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Card from "$lib/components/ui/card";
|
||||||
|
import { formatDistanceToNow } from '$lib/utils/date.js';
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import ProfileInfo from "$lib/components/ui/ProfileInfo.svelte"; // Importer le composant ProfileInfo
|
||||||
|
|
||||||
|
export let userId: string; // Si c'est le message de l'utilisateur courant
|
||||||
|
|
||||||
|
export let message = null; // Contenu du message
|
||||||
|
|
||||||
|
export let setActiveProfile;
|
||||||
|
export let activeProfileId = null;
|
||||||
|
|
||||||
|
let user = null;
|
||||||
|
|
||||||
|
let myMessage;
|
||||||
|
|
||||||
|
async function fetchUser() {
|
||||||
|
const res = await fetch(`/api/users/${message.user.id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
user = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProfileInfo() {
|
||||||
|
if (activeProfileId === message.id) {
|
||||||
|
// Si le profil cliqué est déjà actif, le fermer
|
||||||
|
setActiveProfile(null);
|
||||||
|
} else {
|
||||||
|
// Sinon, afficher ce profil et masquer les autres
|
||||||
|
setActiveProfile(message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeElapsed: string;
|
||||||
|
|
||||||
|
// Fonction pour mettre à jour le temps écoulé
|
||||||
|
const updateElapsed = () => {
|
||||||
|
timeElapsed = formatDistanceToNow(message.createdAt);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialisation de l'intervalle
|
||||||
|
onMount(() => {
|
||||||
|
fetchUser();
|
||||||
|
updateElapsed(); // Calcul initial
|
||||||
|
const interval = setInterval(updateElapsed, 1000); // Mise à jour toutes les secondes
|
||||||
|
myMessage = message.user.id === userId; // Vérifier si c'est le message de l'utilisateur courant
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval); // Nettoyage lors du démontage
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if user !== null}
|
||||||
|
|
||||||
|
<Card.Root class="relative">
|
||||||
|
<Card.Header
|
||||||
|
class="flex items-center justify-between {myMessage ? 'flex-row' : 'flex-row-reverse'}"
|
||||||
|
>
|
||||||
|
<!-- Conteneur pour la date -->
|
||||||
|
<span class="text-xs sm:text-sm md:text-base text-gray-500 items-top">
|
||||||
|
{timeElapsed}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Conteneur pour l'image et le nom d'utilisateur -->
|
||||||
|
<div class="flex items-center gap-3 {myMessage ? 'flex-row-reverse' : 'flex-row'}">
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
on:click={toggleProfileInfo}
|
||||||
|
>
|
||||||
|
<!-- Image de profil -->
|
||||||
|
<img
|
||||||
|
src={`http://localhost:5173/${user.profilePicture}`}
|
||||||
|
alt="Profile Picture"
|
||||||
|
class="h-10 w-10 rounded-full border border-gray-300 cursor-pointer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Infos du profil (affichées au survol) -->
|
||||||
|
<ProfileInfo user={user} show={activeProfileId === message.id} position={myMessage} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col text-right {myMessage ? 'text-right' : 'text-left'}">
|
||||||
|
<Card.Title
|
||||||
|
class="text-gray-800 text-sm sm:text-base md:text-lg truncate {myMessage ? 'font-bold' : ''}"
|
||||||
|
>
|
||||||
|
{myMessage ? "(Moi)" : ""} {user.username}
|
||||||
|
</Card.Title>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<!-- Contenu du message -->
|
||||||
|
<Card.Content class="text-sm sm:text-base md:text-lg text-gray-700">
|
||||||
|
<p>{message.text}</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
<style>
|
||||||
|
img {
|
||||||
|
object-fit: cover; /* Assure un bon rendu des images */
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row-reverse {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
66
src/lib/components/ui/Alert.svelte
Normal file
66
src/lib/components/ui/Alert.svelte
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { fade, fly } from 'svelte/transition'; // Importer fade et fly
|
||||||
|
|
||||||
|
export let message: string = ""; // Le message d'alerte
|
||||||
|
export let onClose: () => void = () => {}; // Fonction de fermeture de l'alerte
|
||||||
|
export let duration: number = 5000;
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
|
||||||
|
// Fonction pour fermer l'alerte
|
||||||
|
const closeAlert = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gestion du timeout pour fermer l'alerte après un certain délai
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (show) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
closeAlert(); // Fermer l'alerte après la durée
|
||||||
|
}, duration);
|
||||||
|
} else {
|
||||||
|
clearTimeout(timeout); // Si l'alerte est fermée, on annule le timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyage du timeout si le composant est démonté avant la fin du délai
|
||||||
|
onDestroy(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div class="alert fixed top-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg"
|
||||||
|
in:fly={{ y: -20, opacity: 0, duration: 300 }}
|
||||||
|
out:fly={{ y: -20, opacity: 0, duration: 300 }}>
|
||||||
|
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Styles de l'alerte */
|
||||||
|
.alert {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #3182ce;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
40
src/lib/components/ui/ChatItem.svelte
Normal file
40
src/lib/components/ui/ChatItem.svelte
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDistanceToNow } from '$lib/utils/date';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let id: string; // ID du chat
|
||||||
|
export let title: string; // Nom ou titre du chat
|
||||||
|
export let lastMessage: string; // Dernier message affiché
|
||||||
|
export let createdAt: string; // Heure du dernier message
|
||||||
|
|
||||||
|
let timeElapsed: string;
|
||||||
|
|
||||||
|
// Fonction pour mettre à jour le temps écoulé
|
||||||
|
const updateElapsed = () => {
|
||||||
|
timeElapsed = formatDistanceToNow(createdAt);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialisation de l'intervalle
|
||||||
|
onMount(() => {
|
||||||
|
updateElapsed(); // Calcul initial
|
||||||
|
const interval = setInterval(updateElapsed, 1000); // Mise à jour toutes les secondes
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval); // Nettoyage lors du démontage
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a href={`/chats/${id}`} class="chat-item p-4 border rounded-md hover:bg-gray-100 cursor-pointer flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-lg">{title}</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">{lastMessage}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400">{timeElapsed}</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-item {
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
112
src/lib/components/ui/ChoosePicture.svelte
Normal file
112
src/lib/components/ui/ChoosePicture.svelte
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let profilePicture: File | null = null;
|
||||||
|
export let defaultImage = '/profile-default.svg'; // Image par défaut si aucune image n'est sélectionnée
|
||||||
|
|
||||||
|
let clientPicture: string | null = profilePicture ? `/${profilePicture}` : defaultImage;
|
||||||
|
|
||||||
|
// Fonction exécutée lorsque l'utilisateur sélectionne une image
|
||||||
|
const handleFileChange = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files?.length) {
|
||||||
|
profilePicture = input.files[0]; // Affectation du fichier sélectionné
|
||||||
|
clientPicture = URL.createObjectURL(profilePicture); // Prévisualisation de l'image
|
||||||
|
} else {
|
||||||
|
clientPicture = null;
|
||||||
|
profilePicture = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
profilePicture = null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Conteneur principal -->
|
||||||
|
<div class="container">
|
||||||
|
<!-- Image de profil ou image par défaut -->
|
||||||
|
<img
|
||||||
|
src={clientPicture}
|
||||||
|
alt="Image de profil"
|
||||||
|
class="image-preview mb-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sélectionner une image -->
|
||||||
|
<label for="profilePicture" class="file-upload-btn">
|
||||||
|
Sélectionner une image
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="profilePicture"
|
||||||
|
class="file-input"
|
||||||
|
accept="image/*"
|
||||||
|
on:change={handleFileChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<!-- Bouton Supprimer l'image -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600"
|
||||||
|
on:click={handleDelete}
|
||||||
|
disabled={!profilePicture}
|
||||||
|
>
|
||||||
|
Supprimer l'image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #3182ce;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-btn:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-btn:active {
|
||||||
|
background-color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%; /* Arrondir l'image en cercle */
|
||||||
|
border: 4px solid #3182ce; /* Bordure autour de l'image */
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons button:disabled {
|
||||||
|
background-color: #cccccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
97
src/lib/components/ui/CreateChat.svelte
Normal file
97
src/lib/components/ui/CreateChat.svelte
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Alert from "$lib/components/ui/Alert.svelte"; // Importer le composant Alert
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
|
||||||
|
export let listRef: HTMLElement | null = null;
|
||||||
|
|
||||||
|
export let onClose: () => void; // Callback pour fermer le composant
|
||||||
|
|
||||||
|
let showAlert = false;
|
||||||
|
let alertMessage = "";
|
||||||
|
|
||||||
|
export let socket;
|
||||||
|
|
||||||
|
let chatName = "";
|
||||||
|
|
||||||
|
const createChat = async () => {
|
||||||
|
if (chatName.trim()) {
|
||||||
|
try {
|
||||||
|
// Appel API pour créer le chat
|
||||||
|
const response = await fetch('/api/channels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: chatName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
alertMessage = `Le chat "${data.name}" a été créé avec succès.`;
|
||||||
|
chatName = ""; // Réinitialiser
|
||||||
|
socket.emit("new-channel", data);
|
||||||
|
if (listRef) {
|
||||||
|
listRef.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
onClose?.(); // Fermer le composant après création
|
||||||
|
} else {
|
||||||
|
response.json().then(error => {
|
||||||
|
alertMessage = error.error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alertMessage = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert = true;
|
||||||
|
} else {
|
||||||
|
alertMessage = "Veuillez entrer un nom pour le chat.";
|
||||||
|
showAlert = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour détecter le clic en dehors
|
||||||
|
let createChatRef: HTMLElement | null = null;
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div class="fixed inset-0 flex justify-center items-center bg-black bg-opacity-50 z-50" on:click={onClose}>
|
||||||
|
<div
|
||||||
|
class="bg-white border border-gray-300 rounded-lg p-8 w-96"
|
||||||
|
bind:this={createChatRef}
|
||||||
|
on:click|stopPropagation
|
||||||
|
>
|
||||||
|
<h1 class="text-2xl font-bold mb-6 text-center">Créer un nouveau chat</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={chatName}
|
||||||
|
placeholder="Nom du chat..."
|
||||||
|
class="w-full border border-gray-300 rounded-lg p-2 focus:outline-none focus:ring focus:border-blue-500 mb-4"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
on:click={createChat}
|
||||||
|
class="bg-blue-500 text-white w-full px-4 py-2 rounded-lg hover:bg-blue-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Alert show={showAlert} message={alertMessage} onClose={() => (showAlert = false)} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fixed {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.bg-white {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
</style>
|
139
src/lib/components/ui/ProfileCard.svelte
Normal file
139
src/lib/components/ui/ProfileCard.svelte
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
<script>
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
|
||||||
|
export let user;
|
||||||
|
export let userSessionId;
|
||||||
|
export let show = false; // Contrôle si la carte est visible
|
||||||
|
export let onClose = () => {}; // Fonction pour fermer la carte
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/disconnect', { method: 'POST' });
|
||||||
|
if (response.redirected) {
|
||||||
|
window.location.href = response.url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la déconnexion:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editProfile = () => {
|
||||||
|
window.location.href = '/user/edit';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div class="overlay" role="dialog" aria-labelledby="profile-card-title" on:click={onClose}>
|
||||||
|
<div class="profile-card flex flex-col gap-5" on:click|stopPropagation>
|
||||||
|
<div class="profile-header">
|
||||||
|
<img src="http://localhost:5173/{user.profilePicture}" alt="Profile" class="profile-image" />
|
||||||
|
<h2 id="profile-card-title" class="profile-name">{user.username}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info">
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Nom :</span>
|
||||||
|
<span class="info-value">{user.surname}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Prénom :</span>
|
||||||
|
<span class="info-value">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Email :</span>
|
||||||
|
<span class="info-value">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if user.id === userSessionId}
|
||||||
|
<div class="actions">
|
||||||
|
<Button on:click={editProfile}>Éditer</Button>
|
||||||
|
<Button on:click={disconnect} variant="destructive">Déconnexion</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
text-align: left;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-image {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #eaeaea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
65
src/lib/components/ui/ProfileInfo.svelte
Normal file
65
src/lib/components/ui/ProfileInfo.svelte
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<script>
|
||||||
|
export let user; // Infos utilisateur
|
||||||
|
export let position = 'right'; // Position de la photo de profil: 'left' ou 'right'
|
||||||
|
export let show = false; // Afficher ou masquer les infos utilisateur
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div class="user-info {position ? 'left' : 'right' }">
|
||||||
|
<h2 class="text-lg font-semibold">{user.username}</h2>
|
||||||
|
<p class="text-sm text-gray-500">{user.email}</p>
|
||||||
|
<p class="text-sm text-gray-500">{user.name} {user.surname}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.user-info {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; /* Aligner en haut */
|
||||||
|
left: auto;
|
||||||
|
right: auto;
|
||||||
|
width: 200px; /* Largeur par défaut */
|
||||||
|
background: rgba(255, 255, 255, 0.95); /* Fond semi-transparent */
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 10; /* Gérer les priorités d'affichage */
|
||||||
|
transition: all 0.3s ease-in-out; /* Animation fluide pour les changements */
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info.left {
|
||||||
|
right: 110%; /* Position à gauche avec un espace */
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info.right {
|
||||||
|
left: 110%; /* Position à droite avec un espace */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media Queries pour adapter le style */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.user-info {
|
||||||
|
width: 150px; /* Réduire la largeur sur les écrans plus petits */
|
||||||
|
padding: 10px; /* Réduire le padding */
|
||||||
|
font-size: 0.9rem; /* Réduire la taille de la police */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.user-info {
|
||||||
|
position: fixed; /* Position fixe pour éviter les débordements */
|
||||||
|
left: 10px; /* Centrer avec un padding interne */
|
||||||
|
right: 10px;
|
||||||
|
width: auto; /* Utiliser toute la largeur disponible */
|
||||||
|
max-width: 90%; /* Limiter la largeur pour éviter les débordements */
|
||||||
|
padding: 8px; /* Réduire le padding davantage */
|
||||||
|
font-size: 0.8rem; /* Police encore plus petite */
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info.left,
|
||||||
|
.user-info.right {
|
||||||
|
left: 10px; /* Ignorer les positionnements relatifs */
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
19
src/lib/components/ui/Search.svelte
Normal file
19
src/lib/components/ui/Search.svelte
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Search } from "lucide-svelte"; // Icône de recherche depuis Lucide Svelte
|
||||||
|
|
||||||
|
export let placeholder: string = "Rechercher..."; // Texte par défaut pour l'input
|
||||||
|
|
||||||
|
export let onChange: (value: string) => void = () => {}; // Callback pour la recherche
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5"/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
class="w-full pl-10 pr-4 py-2 border rounded-md focus:outline-none focus:ring focus:border-blue-300"
|
||||||
|
on:input={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
82
src/lib/components/ui/UserChat.svelte
Normal file
82
src/lib/components/ui/UserChat.svelte
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let user;
|
||||||
|
|
||||||
|
export let openProfileCard = () => {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 justify-between p-3 cursor-pointer hover:bg-gray-100 rounded-lg border border-gray-300 shadow-sm"
|
||||||
|
on:click={openProfileCard}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src={`http://localhost:5173/${user.profilePicture}`}
|
||||||
|
alt="Profile"
|
||||||
|
class="h-12 w-12 rounded-full border border-gray-300"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-medium text-gray-800">{user.username}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-gray-500">{user.state}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if user.state === "En ligne"}
|
||||||
|
<div class="online-indicator"></div>
|
||||||
|
{:else if user.state === "Ecrit"}
|
||||||
|
<div class="typing-animation">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Ajout d'une animation subtile lors du survol */
|
||||||
|
div:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour le point vert (En ligne) */
|
||||||
|
.online-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #22c55e; /* Couleur verte */
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 4px rgba(34, 197, 94, 0.6); /* Glow léger */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour l'animation des trois points */
|
||||||
|
.typing-animation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: #3b82f6; /* Couleur bleue */
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bounce 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
25
src/lib/components/ui/button/button.svelte
Normal file
25
src/lib/components/ui/button/button.svelte
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Button as ButtonPrimitive } from "bits-ui";
|
||||||
|
import { type Events, type Props, buttonVariants } from "./index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = Props;
|
||||||
|
type $$Events = Events;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let variant: $$Props["variant"] = "default";
|
||||||
|
export let size: $$Props["size"] = "default";
|
||||||
|
export let builders: $$Props["builders"] = [];
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ButtonPrimitive.Root
|
||||||
|
{builders}
|
||||||
|
class={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
type="button"
|
||||||
|
{...$$restProps}
|
||||||
|
on:click
|
||||||
|
on:keydown
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ButtonPrimitive.Root>
|
49
src/lib/components/ui/button/index.ts
Normal file
49
src/lib/components/ui/button/index.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
import type { Button as ButtonPrimitive } from "bits-ui";
|
||||||
|
import Root from "./button.svelte";
|
||||||
|
|
||||||
|
const buttonVariants = tv({
|
||||||
|
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Variant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
type Size = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
type Props = ButtonPrimitive.Props & {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Events = ButtonPrimitive.Events;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
type Props,
|
||||||
|
type Events,
|
||||||
|
//
|
||||||
|
Root as Button,
|
||||||
|
type Props as ButtonProps,
|
||||||
|
type Events as ButtonEvents,
|
||||||
|
buttonVariants,
|
||||||
|
};
|
13
src/lib/components/ui/card/card-content.svelte
Normal file
13
src/lib/components/ui/card/card-content.svelte
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("p-6", className)} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
13
src/lib/components/ui/card/card-description.svelte
Normal file
13
src/lib/components/ui/card/card-description.svelte
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLParagraphElement>;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class={cn("text-muted-foreground text-sm", className)} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</p>
|
13
src/lib/components/ui/card/card-footer.svelte
Normal file
13
src/lib/components/ui/card/card-footer.svelte
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex items-center p-6 pt-0", className)} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
13
src/lib/components/ui/card/card-header.svelte
Normal file
13
src/lib/components/ui/card/card-header.svelte
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex flex-col space-y-1.5 p-6 pb-0", className)} {...$$restProps}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
21
src/lib/components/ui/card/card-title.svelte
Normal file
21
src/lib/components/ui/card/card-title.svelte
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { HeadingLevel } from "./index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
|
||||||
|
tag?: HeadingLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let tag: $$Props["tag"] = "h3";
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={tag}
|
||||||
|
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</svelte:element>
|
16
src/lib/components/ui/card/card.svelte
Normal file
16
src/lib/components/ui/card/card.svelte
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn("bg-card text-card-foreground rounded-lg border shadow-sm", className)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
24
src/lib/components/ui/card/index.ts
Normal file
24
src/lib/components/ui/card/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import Root from "./card.svelte";
|
||||||
|
import Content from "./card-content.svelte";
|
||||||
|
import Description from "./card-description.svelte";
|
||||||
|
import Footer from "./card-footer.svelte";
|
||||||
|
import Header from "./card-header.svelte";
|
||||||
|
import Title from "./card-title.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
//
|
||||||
|
Root as Card,
|
||||||
|
Content as CardContent,
|
||||||
|
Description as CardDescription,
|
||||||
|
Footer as CardFooter,
|
||||||
|
Header as CardHeader,
|
||||||
|
Title as CardTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
29
src/lib/components/ui/input/index.ts
Normal file
29
src/lib/components/ui/input/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export type FormInputEvent<T extends Event = Event> = T & {
|
||||||
|
currentTarget: EventTarget & HTMLInputElement;
|
||||||
|
};
|
||||||
|
export type InputEvents = {
|
||||||
|
blur: FormInputEvent<FocusEvent>;
|
||||||
|
change: FormInputEvent<Event>;
|
||||||
|
click: FormInputEvent<MouseEvent>;
|
||||||
|
focus: FormInputEvent<FocusEvent>;
|
||||||
|
focusin: FormInputEvent<FocusEvent>;
|
||||||
|
focusout: FormInputEvent<FocusEvent>;
|
||||||
|
keydown: FormInputEvent<KeyboardEvent>;
|
||||||
|
keypress: FormInputEvent<KeyboardEvent>;
|
||||||
|
keyup: FormInputEvent<KeyboardEvent>;
|
||||||
|
mouseover: FormInputEvent<MouseEvent>;
|
||||||
|
mouseenter: FormInputEvent<MouseEvent>;
|
||||||
|
mouseleave: FormInputEvent<MouseEvent>;
|
||||||
|
mousemove: FormInputEvent<MouseEvent>;
|
||||||
|
paste: FormInputEvent<ClipboardEvent>;
|
||||||
|
input: FormInputEvent<InputEvent>;
|
||||||
|
wheel: FormInputEvent<WheelEvent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
42
src/lib/components/ui/input/input.svelte
Normal file
42
src/lib/components/ui/input/input.svelte
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
import type { InputEvents } from "./index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLInputAttributes;
|
||||||
|
type $$Events = InputEvents;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let value: $$Props["value"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
|
||||||
|
// Workaround for https://github.com/sveltejs/svelte/issues/9305
|
||||||
|
// Fixed in Svelte 5, but not backported to 4.x.
|
||||||
|
export let readonly: $$Props["readonly"] = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class={cn(
|
||||||
|
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{readonly}
|
||||||
|
on:blur
|
||||||
|
on:change
|
||||||
|
on:click
|
||||||
|
on:focus
|
||||||
|
on:focusin
|
||||||
|
on:focusout
|
||||||
|
on:keydown
|
||||||
|
on:keypress
|
||||||
|
on:keyup
|
||||||
|
on:mouseover
|
||||||
|
on:mouseenter
|
||||||
|
on:mouseleave
|
||||||
|
on:mousemove
|
||||||
|
on:paste
|
||||||
|
on:input
|
||||||
|
on:wheel|passive
|
||||||
|
{...$$restProps}
|
||||||
|
/>
|
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
21
src/lib/components/ui/label/label.svelte
Normal file
21
src/lib/components/ui/label/label.svelte
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = LabelPrimitive.Props;
|
||||||
|
type $$Events = LabelPrimitive.Events;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
class={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
on:mousedown
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</LabelPrimitive.Root>
|
18
src/lib/components/ui/tabs/index.ts
Normal file
18
src/lib/components/ui/tabs/index.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import Content from "./tabs-content.svelte";
|
||||||
|
import List from "./tabs-list.svelte";
|
||||||
|
import Trigger from "./tabs-trigger.svelte";
|
||||||
|
|
||||||
|
const Root = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
List,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Tabs,
|
||||||
|
Content as TabsContent,
|
||||||
|
List as TabsList,
|
||||||
|
Trigger as TabsTrigger,
|
||||||
|
};
|
21
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
21
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = TabsPrimitive.ContentProps;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let value: $$Props["value"];
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
class={cn(
|
||||||
|
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{value}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsPrimitive.Content>
|
19
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
19
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = TabsPrimitive.ListProps;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.List
|
||||||
|
class={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsPrimitive.List>
|
23
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
23
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = TabsPrimitive.TriggerProps;
|
||||||
|
type $$Events = TabsPrimitive.TriggerEvents;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let value: $$Props["value"];
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
class={cn(
|
||||||
|
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{value}
|
||||||
|
{...$$restProps}
|
||||||
|
on:click
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsPrimitive.Trigger>
|
28
src/lib/components/ui/textarea/index.ts
Normal file
28
src/lib/components/ui/textarea/index.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import Root from "./textarea.svelte";
|
||||||
|
|
||||||
|
type FormTextareaEvent<T extends Event = Event> = T & {
|
||||||
|
currentTarget: EventTarget & HTMLTextAreaElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextareaEvents = {
|
||||||
|
blur: FormTextareaEvent<FocusEvent>;
|
||||||
|
change: FormTextareaEvent<Event>;
|
||||||
|
click: FormTextareaEvent<MouseEvent>;
|
||||||
|
focus: FormTextareaEvent<FocusEvent>;
|
||||||
|
keydown: FormTextareaEvent<KeyboardEvent>;
|
||||||
|
keypress: FormTextareaEvent<KeyboardEvent>;
|
||||||
|
keyup: FormTextareaEvent<KeyboardEvent>;
|
||||||
|
mouseover: FormTextareaEvent<MouseEvent>;
|
||||||
|
mouseenter: FormTextareaEvent<MouseEvent>;
|
||||||
|
mouseleave: FormTextareaEvent<MouseEvent>;
|
||||||
|
paste: FormTextareaEvent<ClipboardEvent>;
|
||||||
|
input: FormTextareaEvent<InputEvent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Textarea,
|
||||||
|
type TextareaEvents,
|
||||||
|
type FormTextareaEvent,
|
||||||
|
};
|
38
src/lib/components/ui/textarea/textarea.svelte
Normal file
38
src/lib/components/ui/textarea/textarea.svelte
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLTextareaAttributes } from "svelte/elements";
|
||||||
|
import type { TextareaEvents } from "./index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type $$Props = HTMLTextareaAttributes;
|
||||||
|
type $$Events = TextareaEvents;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let value: $$Props["value"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
|
||||||
|
// Workaround for https://github.com/sveltejs/svelte/issues/9305
|
||||||
|
// Fixed in Svelte 5, but not backported to 4.x.
|
||||||
|
export let readonly: $$Props["readonly"] = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
class={cn(
|
||||||
|
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{readonly}
|
||||||
|
on:blur
|
||||||
|
on:change
|
||||||
|
on:click
|
||||||
|
on:focus
|
||||||
|
on:keydown
|
||||||
|
on:keypress
|
||||||
|
on:keyup
|
||||||
|
on:mouseover
|
||||||
|
on:mouseenter
|
||||||
|
on:mouseleave
|
||||||
|
on:paste
|
||||||
|
on:input
|
||||||
|
{...$$restProps}
|
||||||
|
></textarea>
|
21
src/lib/logger.ts
Normal file
21
src/lib/logger.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
|
||||||
|
const { combine, timestamp, json, errors } = winston.format;
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
levels: winston.config.syslog.levels,
|
||||||
|
format: combine(
|
||||||
|
errors({ stack: true }),
|
||||||
|
timestamp(),
|
||||||
|
json(),
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({level: "debug"}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: 'logs/app.log',
|
||||||
|
level: 'debug'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export default logger;
|
4
src/lib/prismaClient.ts
Normal file
4
src/lib/prismaClient.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
export default prisma;
|
16
src/lib/redisClient.ts
Normal file
16
src/lib/redisClient.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
const client = await createClient({
|
||||||
|
url: process.env.REDIS_URL || 'redis://redis-server:6379'
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
client.on('error', (err) => console.error('Redis Error:', err));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Redis Error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default client;
|
4
src/lib/stores/messagesStore.ts
Normal file
4
src/lib/stores/messagesStore.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const messagesStore = writable<string[]>([]);
|
||||||
|
|
9
src/lib/stores/socket.ts
Normal file
9
src/lib/stores/socket.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
// Initialisation de la socket
|
||||||
|
export const initSocket = () => {
|
||||||
|
const socketInstance = io("http://localhost:5173");
|
||||||
|
let socketId = null;
|
||||||
|
|
||||||
|
return socketInstance
|
||||||
|
}
|
62
src/lib/utils.ts
Normal file
62
src/lib/utils.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
import type { TransitionConfig } from "svelte/transition";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlyAndScaleParams = {
|
||||||
|
y?: number;
|
||||||
|
x?: number;
|
||||||
|
start?: number;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const flyAndScale = (
|
||||||
|
node: Element,
|
||||||
|
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
|
||||||
|
): TransitionConfig => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
const transform = style.transform === "none" ? "" : style.transform;
|
||||||
|
|
||||||
|
const scaleConversion = (
|
||||||
|
valueA: number,
|
||||||
|
scaleA: [number, number],
|
||||||
|
scaleB: [number, number]
|
||||||
|
) => {
|
||||||
|
const [minA, maxA] = scaleA;
|
||||||
|
const [minB, maxB] = scaleB;
|
||||||
|
|
||||||
|
const percentage = (valueA - minA) / (maxA - minA);
|
||||||
|
const valueB = percentage * (maxB - minB) + minB;
|
||||||
|
|
||||||
|
return valueB;
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleToString = (
|
||||||
|
style: Record<string, number | string | undefined>
|
||||||
|
): string => {
|
||||||
|
return Object.keys(style).reduce((str, key) => {
|
||||||
|
if (style[key] === undefined) return str;
|
||||||
|
return str + `${key}:${style[key]};`;
|
||||||
|
}, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: params.duration ?? 200,
|
||||||
|
delay: 0,
|
||||||
|
css: (t) => {
|
||||||
|
const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
|
||||||
|
const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
|
||||||
|
const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
|
||||||
|
|
||||||
|
return styleToString({
|
||||||
|
transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
|
||||||
|
opacity: t
|
||||||
|
});
|
||||||
|
},
|
||||||
|
easing: cubicOut
|
||||||
|
};
|
||||||
|
};
|
51
src/lib/utils/date.ts
Normal file
51
src/lib/utils/date.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
export function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false, // format 24 heures
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//fonction qui renvoie le temps depuis la créetion du message à maintenant en fonction du temps on affichera le temps en secondes, minutes, heures, jours, semaines, mois, années
|
||||||
|
export function formatDistanceToNow(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) {
|
||||||
|
return `${hours} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) {
|
||||||
|
return `${days} j`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 4) {
|
||||||
|
return `${weeks} sem`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12) {
|
||||||
|
return `${months} mois`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const years = Math.floor(months / 12);
|
||||||
|
return `${years} ans`;
|
||||||
|
}
|
12
src/lib/utils/sort.ts
Normal file
12
src/lib/utils/sort.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
export function sortChannels(channels) {
|
||||||
|
channels = channels.map((channel) => {
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
lastUpdate : channel.lastMessage != null ? channel.lastMessage.createdAt : channel.createdAt
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return channels.sort((a, b) => {
|
||||||
|
return new Date(b.lastUpdate) - new Date(a.lastUpdate);
|
||||||
|
});
|
||||||
|
}
|
24
src/routes/+layout.server.ts
Normal file
24
src/routes/+layout.server.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
export async function load({ locals, url, fetch }) {
|
||||||
|
|
||||||
|
const token = locals.token;
|
||||||
|
|
||||||
|
if (token == undefined && url.pathname !== "/") {
|
||||||
|
redirect(301, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = null;
|
||||||
|
if (locals.userId !== undefined) {
|
||||||
|
const res = await fetch(`/api/users/${locals.userId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
user = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return { token, user }
|
||||||
|
}
|
|
@ -3,4 +3,6 @@
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
<main class="h-screen">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
77
src/routes/+page.server.ts
Normal file
77
src/routes/+page.server.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import { type Actions } from '@sveltejs/kit';
|
||||||
|
import { redirect, error, fail } from '@sveltejs/kit';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
|
||||||
|
export async function load({locals}) {
|
||||||
|
if (locals.token != undefined) {
|
||||||
|
redirect(302, "/chats")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
login: async ({request, fetch, cookies}) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
cookies.set('token', data.token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: (60 * 60) * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
cookies.set('UID', data.userId, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: (60 * 60) * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug("Successfully created a cookie for the user and proceeded with the login.");
|
||||||
|
|
||||||
|
return redirect(302, "/chats");
|
||||||
|
} else {
|
||||||
|
return fail(400, { error: data.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async ({request, fetch, cookies}) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
cookies.set('token', data.token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: (60 * 60) * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
cookies.set('UID', data.userId, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: (60 * 60) * 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug("Successfully created a cookie for the user and proceeded with the register.")
|
||||||
|
|
||||||
|
return redirect(302, "/chats");
|
||||||
|
} else {
|
||||||
|
return fail(400, { error: data.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,2 +1,101 @@
|
||||||
<h1>Welcome to SvelteKit</h1>
|
<script lang="ts">
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
import Alert from "$lib/components/ui/Alert.svelte";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import * as Card from "$lib/components/ui/card";
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs";
|
||||||
|
let { data, form } = $props();
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const showAlert = writable(false);
|
||||||
|
const alertMessage = writable("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Manipuler l'état via les stores, ce qui est plus réactif
|
||||||
|
if (form?.error) {
|
||||||
|
alertMessage.set(form.error);
|
||||||
|
showAlert.set(true);
|
||||||
|
} else {
|
||||||
|
showAlert.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
let random = Math.trunc(Math.random() * 10);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full h-full flex justify-center items-center">
|
||||||
|
|
||||||
|
<Tabs.Root value="login" class="w-[450px]">
|
||||||
|
<Tabs.List class="grid w-full grid-cols-2">
|
||||||
|
<Tabs.Trigger value="login">Se connecter</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="register">S'inscrire</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content value="login">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
{#if random > 8}
|
||||||
|
<Card.Title>🌳 - Arabes</Card.Title>
|
||||||
|
{:else}
|
||||||
|
<Card.Title>🌳 - Arbres</Card.Title>
|
||||||
|
{/if}
|
||||||
|
<Card.Description>Connectez vous pour chatter!</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<form method="POST" action="?/login" use:enhance>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label for="email">Adresse email</Label>
|
||||||
|
<Input type="text" name="email" id="email" />
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label for="password">Mot de passe</Label>
|
||||||
|
<Input type="password" name="password" id="password" />
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer>
|
||||||
|
<Button type="submit">Se connecter</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</form>
|
||||||
|
</Card.Root>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="register">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
{#if random > 8}
|
||||||
|
<Card.Title>🌳 - Arabes</Card.Title>
|
||||||
|
{:else}
|
||||||
|
<Card.Title>🌳 - Arbres</Card.Title>
|
||||||
|
{/if}
|
||||||
|
<Card.Description>Inscrivez-vous pour chatter!</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<form method="POST" action="?/register" use:enhance>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label for="username">Nom d'utilisateur</Label>
|
||||||
|
<Input type="text" name="username" id="username" />
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label for="email">Adresse email</Label>
|
||||||
|
<Input type="text" name="email" id="email" />
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label for="password">Mot de passe</Label>
|
||||||
|
<Input type="password" name="password" id="password" />
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer>
|
||||||
|
<Button type="submit">S'inscrire</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</form>
|
||||||
|
</Card.Root>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
</Tabs.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert message={$alertMessage} show={$showAlert} onClose={() => ($showAlert = false)} />
|
46
src/routes/api/auth/login/+server.ts
Normal file
46
src/routes/api/auth/login/+server.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import prismaClient from '$lib/prismaClient';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
|
||||||
|
export async function POST({request}) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const email: string = formData.get('email').toString();
|
||||||
|
// @ts-ignore
|
||||||
|
const password: string = formData.get('password').toString();
|
||||||
|
|
||||||
|
const user = await prismaClient.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: email,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
logger.debug(`Could not find user with email (${email}) in database`);
|
||||||
|
return error(400, {message: "Email ou mot de passe invalide."});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Found user with email (${email}) in database`);
|
||||||
|
try {
|
||||||
|
if (await argon2.verify(user.password, password)) {
|
||||||
|
logger.debug(`Password for user ${user.email} is correct.`);
|
||||||
|
// @ts-ignore
|
||||||
|
const token = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
logger.debug(`Generated a JWT token for user ${user.email}.`)
|
||||||
|
return json({token: token, userId: user.id});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return error(400, {message: "Email ou mot de passe invalide."});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return error(500, {message: e.body.message});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
55
src/routes/api/auth/register/+server.ts
Normal file
55
src/routes/api/auth/register/+server.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import prismaClient from '$lib/prismaClient';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
|
||||||
|
export async function POST({request}) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const username: string = formData.get('username').toString().toLowerCase();
|
||||||
|
// @ts-ignore
|
||||||
|
const email: string = formData.get('email').toString().toLowerCase();
|
||||||
|
// @ts-ignore
|
||||||
|
const password: string = formData.get('password').toString();
|
||||||
|
|
||||||
|
const user = await prismaClient.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: username },
|
||||||
|
{ email: email },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user != null) {
|
||||||
|
logger.debug(`A user with email (${email}) already exists in database`);
|
||||||
|
return error(400, {message: "Un compte avec cette adresse email ou nom d'utilisateur existe déjà."});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = await argon2.hash(password);
|
||||||
|
|
||||||
|
const newUser = await prismaClient.user.create({
|
||||||
|
data: {
|
||||||
|
username: username,
|
||||||
|
email: email,
|
||||||
|
password: hash,
|
||||||
|
surname: "",
|
||||||
|
name: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const token = jwt.sign(newUser, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
logger.debug(`Generated a JWT token for user ${newUser.email}.`)
|
||||||
|
return json({token: token, userId: newUser.id});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return error(500, {message: "Erreur interne."});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
139
src/routes/api/channels/+server.ts
Normal file
139
src/routes/api/channels/+server.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import prisma from '$lib/prismaClient';
|
||||||
|
import redisClient from '$lib/redisClient';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
import { sortChannels } from '$lib/utils/sort.ts';
|
||||||
|
|
||||||
|
// GET: Liste tous les canaux avec leur premier message
|
||||||
|
export async function GET({ url }) {
|
||||||
|
if(url.searchParams.get("name") != null && url.searchParams.get("name") != ""){
|
||||||
|
const name = url.searchParams.get("name");
|
||||||
|
try {
|
||||||
|
let canaux = await prisma.channel.findMany({
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
contains: name,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
take: 1, // Récupère le dernier message
|
||||||
|
orderBy: { createdAt: 'desc' },// Trie par date décroissante
|
||||||
|
// as lastMessage not list last message
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
canaux = canaux.map((canaux) => {
|
||||||
|
return {
|
||||||
|
...canaux,
|
||||||
|
lastMessage: canaux.messages.length > 0 ? canaux.messages[0] : null,
|
||||||
|
messages: undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
canaux = sortChannels(canaux);
|
||||||
|
|
||||||
|
return json(canaux);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
return json({ error: 'Erreur serveur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
try {
|
||||||
|
|
||||||
|
let channels = [];
|
||||||
|
|
||||||
|
const cachedChannels = await redisClient.get('channels');
|
||||||
|
|
||||||
|
if (cachedChannels != null) {
|
||||||
|
logger.debug('Cache entry found, fetching channels from cache');
|
||||||
|
channels = JSON.parse(cachedChannels);
|
||||||
|
}else{
|
||||||
|
logger.debug('No cache entry was found, fetching channels from database');
|
||||||
|
}
|
||||||
|
if(channels.length < 10){
|
||||||
|
logger.debug('Fetching channels from database to fill cache');
|
||||||
|
let canaux = await prisma.channel.findMany({
|
||||||
|
include: {
|
||||||
|
messages: {
|
||||||
|
take: 1, // Récupère le dernier message
|
||||||
|
orderBy: { createdAt: 'desc' }, // Trie par date décroissante
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
canaux = canaux.map((canaux) => {
|
||||||
|
return {
|
||||||
|
...canaux,
|
||||||
|
lastMessage: canaux.messages.length > 0 ? canaux.messages[0] : null,
|
||||||
|
messages: undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
channels = channels.concat(canaux);
|
||||||
|
|
||||||
|
channels = channels.filter((channel, index, self) =>
|
||||||
|
index === self.findIndex((t) => (
|
||||||
|
t.id === channel.id
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
channels = sortChannels(channels);
|
||||||
|
|
||||||
|
channels = channels.slice(0, 10);
|
||||||
|
|
||||||
|
await redisClient.set('channels', JSON.stringify(channels), { EX: 3600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(channels);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err)
|
||||||
|
return json({ error: 'Erreur serveur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const { name } = await request.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let canal = await prisma.channel.create({
|
||||||
|
data: {
|
||||||
|
name
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.debug('Creating a new channel in database with id ' + canal.id);
|
||||||
|
|
||||||
|
const cachedChanels = await redisClient.get('channels');
|
||||||
|
|
||||||
|
let channels = cachedChanels != null ? JSON.parse(cachedChanels) : [];
|
||||||
|
|
||||||
|
canal = {
|
||||||
|
...canal,
|
||||||
|
lastMessage: null,
|
||||||
|
lastUpdate: canal.createdAt,
|
||||||
|
messages: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
channels.push(canal);
|
||||||
|
|
||||||
|
channels = sortChannels(channels);
|
||||||
|
|
||||||
|
logger.debug(`Added channel (${canal.id}) to channels cache.`);
|
||||||
|
await redisClient.set('channels', JSON.stringify(channels), { EX: 600 });
|
||||||
|
|
||||||
|
return json(canal, { status: 201 });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
logger.error(err);
|
||||||
|
return json({ error: 'Erreur lors de la création du canal' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
148
src/routes/api/channels/[id]/+server.ts
Normal file
148
src/routes/api/channels/[id]/+server.ts
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import prisma from '$lib/prismaClient';
|
||||||
|
import redisClient from '$lib/redisClient';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
|
||||||
|
// Récupérer les informations du canal et le dernier message (avec cache Redis)
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const channelId = params.id;
|
||||||
|
|
||||||
|
const channelCacheKey = `channel:${channelId}:info`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cachedChannel = await redisClient.get(channelCacheKey);
|
||||||
|
if (cachedChannel) {
|
||||||
|
logger.debug(`Cache entry found, fetching channel (${channelId}) from cache`);
|
||||||
|
return json(JSON.parse(cachedChannel));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`No cache entry was found, fetching channel (${channelId}) from database`);
|
||||||
|
const canal = await prisma.channel.findUnique({
|
||||||
|
where: { id: channelId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canal) {
|
||||||
|
logger.debug(`No channel for id ${channelId} was found in database`)
|
||||||
|
return json({ error: 'Canal non trouvé' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMessage = await prisma.message.findFirst({
|
||||||
|
where: { id: channelId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer un objet combiné pour le canal et le dernier message
|
||||||
|
const canalData = {
|
||||||
|
canal,
|
||||||
|
lastMessage, // Inclure uniquement le dernier message
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const cachedChanels = await redisClient.get('channels');
|
||||||
|
let channels = cachedChanels != null ? JSON.parse(cachedChanels) : [];
|
||||||
|
|
||||||
|
channels.push(canal);
|
||||||
|
|
||||||
|
channels = channels.sort(
|
||||||
|
(
|
||||||
|
a: { messages: { createdAt: Date }[]; createdAt: Date },
|
||||||
|
b: { messages: { createdAt: Date }[]; createdAt: Date }
|
||||||
|
) => {
|
||||||
|
const lastMessageA = a.messages[0]?.createdAt || a.createdAt ? a.createdAt : new Date();
|
||||||
|
const lastMessageB = b.messages[0]?.createdAt || b.createdAt ? b.createdAt : new Date();
|
||||||
|
return new Date(lastMessageB).getTime() - new Date(lastMessageA).getTime();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(`Added channel (${canal.id}) to channels cache.`);
|
||||||
|
await redisClient.set('channels', JSON.stringify(channels), { EX: 600 });
|
||||||
|
|
||||||
|
logger.debug(`Creating a new cache entry with key channel:${channelId}:info`);
|
||||||
|
await redisClient.set(channelCacheKey, JSON.stringify(canalData), {EX: 600, NX: true}); // Cache pendant 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
return json(canalData);
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
logger.error(err);
|
||||||
|
return json({ error: 'Erreur lors de la récupération du canal ou du dernier message' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer un canal et invalider le cache associé
|
||||||
|
export async function DELETE({ params }) {
|
||||||
|
const channelId = params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Supprimer le canal de la base de données
|
||||||
|
await prisma.channel.delete({
|
||||||
|
where: { id: channelId },
|
||||||
|
});
|
||||||
|
logger.debug(`Deleting channel (${channelId}) from database`);
|
||||||
|
|
||||||
|
logger.debug(`Deleting channel (${channelId}) from cache`);
|
||||||
|
await redisClient.del(`channel:${channelId}:info`);
|
||||||
|
|
||||||
|
return json({ message: 'Canal supprimé avec succès' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return json({ error: 'Erreur lors de la suppression du canal' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modifier un canal
|
||||||
|
export async function PUT({ params, request }) {
|
||||||
|
const channelId = params.id;
|
||||||
|
const { nom } = await request.json();
|
||||||
|
|
||||||
|
// Clé cache pour les informations du canal et le dernier message
|
||||||
|
const canalCacheKey = `channel:${channelId}:info`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mettre à jour les informations du canal dans la base de données
|
||||||
|
const updatedCanal = await prisma.channel.update({
|
||||||
|
where: { id: channelId },
|
||||||
|
data: {
|
||||||
|
name: nom,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupérer le dernier message associé au canal après mise à jour
|
||||||
|
const lastMessage = await prisma.message.findFirst({
|
||||||
|
where: { id: channelId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer un objet combiné pour les nouvelles informations du canal et le dernier message
|
||||||
|
const canalData = {
|
||||||
|
canal: updatedCanal,
|
||||||
|
lastMessage, // Inclure uniquement le dernier message
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedChannels = await redisClient.get('channels');
|
||||||
|
let channelsArrays = cachedChannels != null ? JSON.parse(cachedChannels) : [];
|
||||||
|
channelsArrays = channelsArrays.filter((u: { id: string }) => u.id !== updatedCanal.id);
|
||||||
|
channelsArrays.push(canalData);
|
||||||
|
|
||||||
|
channelsArrays = channelsArrays.sort(
|
||||||
|
(
|
||||||
|
a: { messages: { createdAt: Date }[]; createdAt: Date },
|
||||||
|
b: { messages: { createdAt: Date }[]; createdAt: Date }
|
||||||
|
) => {
|
||||||
|
const lastMessageA = a.messages[0]?.createdAt || a.createdAt ? a.createdAt : new Date();
|
||||||
|
const lastMessageB = b.messages[0]?.createdAt || b.createdAt ? b.createdAt : new Date();
|
||||||
|
return new Date(lastMessageB).getTime() - new Date(lastMessageA).getTime();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(`Updated channel (${channelId}) in channels cache`);
|
||||||
|
await redisClient.set('channels', JSON.stringify(channelsArrays), { EX: 600 })
|
||||||
|
logger.debug(`Updated cache entry with key channel:${channelId}:info`);
|
||||||
|
await redisClient.set(canalCacheKey, JSON.stringify(canalData), { EX: 600, NX: true });
|
||||||
|
|
||||||
|
return json(canalData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return json({ error: 'Erreur lors de la mise à jour du canal' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
197
src/routes/api/channels/[id]/messages/+server.ts
Normal file
197
src/routes/api/channels/[id]/messages/+server.ts
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import prisma from '$lib/prismaClient';
|
||||||
|
import redisClient from '$lib/redisClient';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
import { sortChannels } from '$lib/utils/sort.ts';
|
||||||
|
|
||||||
|
export async function GET({ params, url }) {
|
||||||
|
const channelId = params.id;
|
||||||
|
logger.debug(`GET /api/channels/${channelId}/messages`);
|
||||||
|
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||||
|
const page = parseInt(url.searchParams.get('page') || '1');
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(`Tentative de récupération des messages du cache pour le channel : ${channelId}`);
|
||||||
|
let redisMessageKeys = await redisClient.zRangeWithScores(
|
||||||
|
`channel:${channelId}:messages`,
|
||||||
|
offset,
|
||||||
|
offset + limit - 1,
|
||||||
|
{ REV: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const redisPipelineRemove = redisClient.multi();
|
||||||
|
|
||||||
|
|
||||||
|
for (const messageKey of redisMessageKeys) {
|
||||||
|
// Vérifie si la clé existe dans Redis
|
||||||
|
const messageKeyValue = messageKey.value;
|
||||||
|
const exists = await redisClient.exists(messageKeyValue);
|
||||||
|
if (!exists) {
|
||||||
|
// Supprime la référence expirée dans le zSet
|
||||||
|
redisPipelineRemove.zRem(`channel:${channelId}:messages`, messageKeyValue);
|
||||||
|
redisMessageKeys = redisMessageKeys.filter((key) => key.value !== messageKeyValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await redisPipelineRemove.exec();
|
||||||
|
|
||||||
|
if (redisMessageKeys.length > 0) {
|
||||||
|
const messages = await Promise.all(
|
||||||
|
redisMessageKeys.map(async (key) => {
|
||||||
|
const message = await redisClient.get(key.value);
|
||||||
|
return JSON.parse(message);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const redisPipeline = redisClient.multi();
|
||||||
|
for (const key of redisMessageKeys) {
|
||||||
|
const message = await redisClient.get(key.value);
|
||||||
|
const msg = JSON.parse(message)
|
||||||
|
redisPipeline.set(key.value, JSON.stringify(msg), {EX: 1800});
|
||||||
|
redisPipeline.zAdd(`channel:${channelId}:messages`, {
|
||||||
|
score: key.score,
|
||||||
|
value: key.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await redisPipeline.exec();
|
||||||
|
|
||||||
|
return json({ limit, page, messages: messages.reverse() });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Aucun message trouvé dans le cache, récupération depuis MongoDB pour le channel : ${channelId}`);
|
||||||
|
const messagesFromDB = await prisma.message.findMany({
|
||||||
|
where: { channelId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
text: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (messagesFromDB.length > 0) {
|
||||||
|
const redisPipeline = redisClient.multi();
|
||||||
|
for (const message of messagesFromDB) {
|
||||||
|
const messageKey = `message:${message.id}`;
|
||||||
|
redisPipeline.set(messageKey, JSON.stringify(message), {EX: 1800});
|
||||||
|
redisPipeline.zAdd(`channel:${channelId}:messages`, {
|
||||||
|
score: new Date(message.createdAt).getTime(),
|
||||||
|
value: messageKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await redisPipeline.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ limit, page, messages: messagesFromDB.reverse() });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Erreur lors de la récupération des messages : ${err.message}`);
|
||||||
|
return json({ error: 'Erreur lors de la récupération des messages' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ params, request }) {
|
||||||
|
const channelId = params.id;
|
||||||
|
const { userId, text } = await request.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Créer un nouveau message dans MongoDB
|
||||||
|
let newMessage = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
text: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channel: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajouter le message dans Redis
|
||||||
|
await redisClient.set(`message:${newMessage.id}`, JSON.stringify(newMessage), {EX: 1800});
|
||||||
|
await redisClient.zAdd(`channel:${channelId}:messages`, {
|
||||||
|
score: new Date(newMessage.createdAt).getTime(),
|
||||||
|
value: `message:${newMessage.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
//update the channels cache with the new message
|
||||||
|
const cachedChannels = await redisClient.get('channels');
|
||||||
|
let channels = cachedChannels ? JSON.parse(cachedChannels) : [];
|
||||||
|
let channel = channels.find((c) => c.id === channelId);
|
||||||
|
if(channel){
|
||||||
|
channel.lastMessage = {
|
||||||
|
id: newMessage.id,
|
||||||
|
text: newMessage.text,
|
||||||
|
user: newMessage.user,
|
||||||
|
createdAt: newMessage.createdAt,
|
||||||
|
};
|
||||||
|
channel.lastUpdate = newMessage.createdAt;
|
||||||
|
channel.messages = undefined;
|
||||||
|
|
||||||
|
}else{
|
||||||
|
channel = {...newMessage.channel, lastMessage: {
|
||||||
|
id: newMessage.id,
|
||||||
|
text: newMessage.text,
|
||||||
|
user: newMessage.user,
|
||||||
|
createdAt: newMessage.createdAt,
|
||||||
|
}, lastUpdate: newMessage.createdAt, messages: undefined};
|
||||||
|
channels = [channel, ...channels];
|
||||||
|
}
|
||||||
|
await redisClient.set('channels', JSON.stringify(channels), { EX: 600 });
|
||||||
|
|
||||||
|
newMessage.channel = {
|
||||||
|
id: newMessage.channel.id,
|
||||||
|
name: newMessage.channel.name,
|
||||||
|
lastMessage: channel.lastMessage,
|
||||||
|
lastUpdate: channel.lastUpdate,
|
||||||
|
messages: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug(`Nouveau message ajouté pour le channel : ${channelId}`);
|
||||||
|
return json(newMessage, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Erreur lors de la création du message : ${err.message}`);
|
||||||
|
return json({ error: 'Erreur lors de la création du message' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE({ params, request }) {
|
||||||
|
const channelId = params.id;
|
||||||
|
const { messageId } = await request.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Supprimer le message dans MongoDB
|
||||||
|
await prisma.message.delete({ where: { id: messageId } });
|
||||||
|
|
||||||
|
// Supprimer le message dans Redis
|
||||||
|
await redisClient.del(`message:${messageId}`);
|
||||||
|
await redisClient.zRem(`channel:${channelId}:messages`, `message:${messageId}`);
|
||||||
|
|
||||||
|
logger.debug(`Message supprimé pour le channel : ${channelId}`);
|
||||||
|
return json({ message: 'Message supprimé avec succès' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Erreur lors de la suppression du message : ${err.message}`);
|
||||||
|
return json({ error: 'Erreur lors de la suppression du message' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
62
src/routes/api/users/+server.ts
Normal file
62
src/routes/api/users/+server.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// src/routes/api/users/+server.js
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import redisClient from '$lib/redisClient';
|
||||||
|
import prisma from '$lib/prismaClient';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Vérifier si les utilisateurs sont dans le cache Redis
|
||||||
|
const cachedUsers = await redisClient.get('users');
|
||||||
|
if (cachedUsers) {
|
||||||
|
logger.debug('Cache entry found, fetching users from cache');
|
||||||
|
return json(JSON.parse(cachedUsers));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('No cache entry was found, fetching users from database');
|
||||||
|
// Sinon, récupérer les utilisateurs depuis MongoDB
|
||||||
|
const users = await prisma.user.findMany();
|
||||||
|
|
||||||
|
// Mettre les utilisateurs en cache
|
||||||
|
logger.debug('Caching users with EX of 600 secs');
|
||||||
|
await redisClient.set('users', JSON.stringify(users), { EX: 600 });
|
||||||
|
|
||||||
|
return json(users);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
return json({ error: 'Erreur serveur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const { username, surname, name, email, password } = await request.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: username.toLowerCase(),
|
||||||
|
surname,
|
||||||
|
name,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.debug('Creating a new user in database with id ' + user.id);
|
||||||
|
|
||||||
|
// Mettre le nouvel utilisateur dans le cache
|
||||||
|
logger.debug(`Caching user (${user.id})`);
|
||||||
|
const cachedUsers = await redisClient.get('users');
|
||||||
|
const usersArray = cachedUsers != null ? JSON.parse(cachedUsers) : [];
|
||||||
|
usersArray.push(user);
|
||||||
|
|
||||||
|
logger.debug(`Added user (${user.id}) to users cache.`);
|
||||||
|
await redisClient.set('users', JSON.stringify(usersArray), { EX: 600 })
|
||||||
|
logger.debug(`Creating a new cache entry with key user:${user.id}, with EX of 3600 secs`);
|
||||||
|
await redisClient.set(`user:${user.id}`, JSON.stringify(user), { EX: 3600 });
|
||||||
|
|
||||||
|
return json(user, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err)
|
||||||
|
return json({ error: 'Erreur lors de la création de l’utilisateur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
136
src/routes/api/users/[id]/+server.ts
Normal file
136
src/routes/api/users/[id]/+server.ts
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import redisClient from '$lib/redisClient';
|
||||||
|
import prisma from '$lib/prismaClient';
|
||||||
|
import logger from '$lib/logger';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { extname } from 'path';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier si l'utilisateur est dans le cache Redis
|
||||||
|
const cachedUser = await redisClient.get(`user:${userId}`);
|
||||||
|
if (cachedUser) {
|
||||||
|
logger.debug(`Cache entry found, fetching user (${params.id}) from cache`);
|
||||||
|
return json(JSON.parse(cachedUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`No cache entry was found, fetching user (${params.id}) from database`);
|
||||||
|
// Si non, récupérer depuis MongoDB via Prisma
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.debug(`No record of user (${params.id}) found in database`);
|
||||||
|
return json({ error: 'Utilisateur non trouvé' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre l'utilisateur en cache
|
||||||
|
const cachedUsers = await redisClient.get('users');
|
||||||
|
const usersArray = cachedUsers != null ? JSON.parse(cachedUsers) : [];
|
||||||
|
usersArray.push(user);
|
||||||
|
logger.debug(`Added user (${user.id}) to users cache.`);
|
||||||
|
await redisClient.set('users', JSON.stringify(usersArray), { EX: 600 })
|
||||||
|
logger.debug(`Creating a new cache entry with key user:${user.id}, with EX of 3600 secs`);
|
||||||
|
await redisClient.set(`user:${userId}`, JSON.stringify(user), { EX: 3600 });
|
||||||
|
|
||||||
|
return json(user);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
return json({ error: 'Erreur serveur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour un utilisateur avec PUT
|
||||||
|
export async function PUT({ params, request }) {
|
||||||
|
const userId = params.id;
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const data: {username?: string, email?: string, surname?: string, name?: string, password?: string, profilePicture?: string} = {};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const username = formData.get('username').toString();
|
||||||
|
// @ts-ignore
|
||||||
|
const surname = formData.get('surname').toString();
|
||||||
|
// @ts-ignore
|
||||||
|
const name = formData.get('name').toString();
|
||||||
|
// @ts-ignore
|
||||||
|
const email = formData.get('email').toString();
|
||||||
|
// @ts-ignore
|
||||||
|
const password = formData?.get('password');
|
||||||
|
// @ts-ignore
|
||||||
|
const profilePicture: File | null = formData?.get('profilePicture');
|
||||||
|
|
||||||
|
|
||||||
|
let filename: string | null = null;
|
||||||
|
if (profilePicture != null) {
|
||||||
|
filename = `${crypto.randomUUID()}${extname(profilePicture?.name)}`;
|
||||||
|
await writeFile(`static/${filename}`, Buffer.from(await profilePicture?.arrayBuffer()));
|
||||||
|
data.profilePicture = filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password != null) {
|
||||||
|
data.password = await argon2.hash(password.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
data.username = username;
|
||||||
|
data.surname = surname;
|
||||||
|
data.name = name;
|
||||||
|
data.email = email;
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
logger.debug(`Updated user (${updatedUser.id}) in database`);
|
||||||
|
|
||||||
|
// Mettre à jour l'utilisateur dans le cache Redis
|
||||||
|
const cachedUsers = await redisClient.get('users');
|
||||||
|
let usersArray = cachedUsers != null ? JSON.parse(cachedUsers) : [];
|
||||||
|
usersArray = usersArray.filter((u: { id: string }) => u.id !== updatedUser.id);
|
||||||
|
usersArray.push(updatedUser);
|
||||||
|
logger.debug(`Updated user (${updatedUser.id}) in users cache.`);
|
||||||
|
await redisClient.set('users', JSON.stringify(usersArray), { EX: 600 })
|
||||||
|
logger.debug(`Updated cache entry with key user:${updatedUser.id}`);
|
||||||
|
await redisClient.set(`user:${userId}`, JSON.stringify(updatedUser), { EX: 3600 }); // Cache pendant 1 heure (3600 secondes)
|
||||||
|
|
||||||
|
return json(updatedUser);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
return json({ error: 'Erreur lors de la mise à jour de l’utilisateur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function DELETE({ params }) {
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletedUser = await prisma.user.delete({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
logger.debug(`Deleted user (${deletedUser.id}) from database.`);
|
||||||
|
|
||||||
|
// Supprimer l'utilisateur du cache Redis
|
||||||
|
const cachedUsers = await redisClient.get('users');
|
||||||
|
let usersArray = cachedUsers != null ? JSON.parse(cachedUsers) : [];
|
||||||
|
usersArray = usersArray.filter((u: { id: string }) => u.id !== deletedUser.id);
|
||||||
|
logger.debug(`Deleted cache entry with key user:${deletedUser.id}`);
|
||||||
|
await redisClient.del(`user:${userId}`);
|
||||||
|
logger.debug(`Deleted user (${deletedUser.id}) from users cache.`);
|
||||||
|
await redisClient.set('users', JSON.stringify(usersArray), { EX: 600 })
|
||||||
|
|
||||||
|
return json({ message: 'Utilisateur supprimé avec succès' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
return json({ error: 'Erreur lors de la suppression de l’utilisateur' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
25
src/routes/chats/+page.server.ts
Normal file
25
src/routes/chats/+page.server.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
export async function load({ fetch, locals }) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Appel API ou récupération de données
|
||||||
|
const res = await fetch('/api/channels', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const channels = await res.json();
|
||||||
|
|
||||||
|
// Retourner les données à la page sous forme de props
|
||||||
|
return {
|
||||||
|
channels,
|
||||||
|
userId: locals.userId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des canaux:', error);
|
||||||
|
return {
|
||||||
|
channels: [],
|
||||||
|
userId: locals.userId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
118
src/routes/chats/+page.svelte
Normal file
118
src/routes/chats/+page.svelte
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import Plus from "svelte-radix/Plus.svelte"; // Icône pour "Ajouter"
|
||||||
|
import Search from "$lib/components/ui/Search.svelte";
|
||||||
|
import ChatItem from "$lib/components/ui/ChatItem.svelte";
|
||||||
|
import ProfileCard from "$lib/components/ui/ProfileCard.svelte"; // Importer le composant ProfileCard
|
||||||
|
import CreateChat from "$lib/components/ui/CreateChat.svelte"; // Importer le composant CreateChat
|
||||||
|
import { initSocket } from "$lib/stores/socket";
|
||||||
|
|
||||||
|
let chatListRef: HTMLElement | null = null;
|
||||||
|
|
||||||
|
let showProfileCard = false; // État pour afficher ou masquer le ProfileCard
|
||||||
|
let showCreateChat = false; // État pour afficher ou masquer CreateChat
|
||||||
|
|
||||||
|
let socket = initSocket(); // Initialiser le socket
|
||||||
|
|
||||||
|
socket.on("new-channel", (channel) => {
|
||||||
|
channels = [channel, ...channels];
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("new-message", (message) => {
|
||||||
|
const channel = message.channel
|
||||||
|
if(channels.find(c => c.id === channel.id)) {
|
||||||
|
channels = channels.map((c) => {
|
||||||
|
if (c.id === channel.id) {
|
||||||
|
c.lastMessage = message;
|
||||||
|
c.lastUpdate = message.createdAt;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
channels = [channel, ...channels.filter((c) => c.id !== channel.id)];
|
||||||
|
}else{
|
||||||
|
channels = [channel, ...channels];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openProfileCard() {
|
||||||
|
showProfileCard = true; // Inverser l'état pour afficher/masquer le ProfilCard
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfileCard() {
|
||||||
|
showProfileCard = false; // Inverser l'état pour afficher/masquer le ProfilCard
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateChat() {
|
||||||
|
showCreateChat = true; // Afficher le composant CreateChat
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateChat() {
|
||||||
|
showCreateChat = false; // Fermer le composant CreateChat
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNewChannels(name : string) {
|
||||||
|
console.log('loadNewChannels');
|
||||||
|
const res = await fetch(`/api/channels?name=${name}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
channels = await res.json();
|
||||||
|
console.log(channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
export let channels = data.channels;// Assurez-vous que 'lastMessage' est facultatif si nécessaire
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col gap-5 p-5">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<!-- Bouton Profile avec l'image de l'utilisateur -->
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
on:click={openProfileCard}
|
||||||
|
>
|
||||||
|
<img src={data.user.profilePicture} alt="Profile" class="h-8 w-8 rounded-full" />
|
||||||
|
Profile
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 w-full mr-6 ml-6">
|
||||||
|
<div class="relative w-full">
|
||||||
|
<Search class="absolute left-2 top-1/2 transform -translate-y-1/2 text-gray-400" onChange={loadNewChannels}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton Nouveau Chat -->
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
class="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
on:click={openCreateChat}
|
||||||
|
>
|
||||||
|
<Plus class="h-5 w-5" />
|
||||||
|
Nouveau Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 overflow-y-auto" bind:this={chatListRef}>
|
||||||
|
{#each channels as channel}
|
||||||
|
<ChatItem
|
||||||
|
id={channel.id}
|
||||||
|
title={channel.name}
|
||||||
|
lastMessage={channel.lastMessage ? channel.lastMessage.text : "Ecrire le premier message"}
|
||||||
|
createdAt={channel.lastUpdate}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<CreateChat show={showCreateChat} socket={socket} onClose={closeCreateChat} listRef={chatListRef} />
|
||||||
|
<ProfileCard user={data.user} userSessionId={data.userId} show={showProfileCard} onClose={closeProfileCard} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
</style>
|
33
src/routes/chats/[id]/+page.server.ts
Normal file
33
src/routes/chats/[id]/+page.server.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
export async function load({ fetch, params, locals }) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/${params.id}/messages?page=1&limit=10`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const messages = await res.json();
|
||||||
|
const resUser = await fetch(`/api/users/${locals.userId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const user = await resUser.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
channelId: params.id,
|
||||||
|
userId: locals.userId,
|
||||||
|
user: user
|
||||||
|
}
|
||||||
|
}catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des messages:', error);
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
channelId: params.id,
|
||||||
|
userId: locals.userId,
|
||||||
|
user: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
338
src/routes/chats/[id]/+page.svelte
Normal file
338
src/routes/chats/[id]/+page.svelte
Normal file
|
@ -0,0 +1,338 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Textarea from "$lib/components/ui/textarea/textarea.svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import PaperPlane from "svelte-radix/PaperPlane.svelte";
|
||||||
|
import Message from "$lib/components/Message.svelte";
|
||||||
|
import UserChat from '$lib/components/ui/UserChat.svelte';
|
||||||
|
import { tick, onDestroy, onMount } from 'svelte';
|
||||||
|
import { initSocket } from '$lib/stores/socket';
|
||||||
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
|
import { messagesStore } from '$lib/stores/messagesStore';
|
||||||
|
import ProfileCard from '$lib/components/ui/ProfileCard.svelte';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
messagesStore.set(data.messages.messages);
|
||||||
|
|
||||||
|
let user = data.user;
|
||||||
|
|
||||||
|
const socket = initSocket(); // Initialiser le socket
|
||||||
|
let users= [];
|
||||||
|
|
||||||
|
let scrollContainer: HTMLElement;
|
||||||
|
let messageText = '';
|
||||||
|
|
||||||
|
let activeProfileId = null;
|
||||||
|
let userChatSelected = {
|
||||||
|
id: '',
|
||||||
|
username: '',
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
email: '',
|
||||||
|
profilePicture: '',
|
||||||
|
state: '',
|
||||||
|
};
|
||||||
|
let showProfileCard = false;
|
||||||
|
|
||||||
|
function openProfileCard(user) {
|
||||||
|
userChatSelected = user;
|
||||||
|
showProfileCard = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfileCard() {
|
||||||
|
showProfileCard = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveProfile(id) {
|
||||||
|
activeProfileId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
// Appel API pour envoyer le message
|
||||||
|
const response = await fetch(`/api/channels/${data.channelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId: data.userId, text: messageText }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
let newMessage =await response.json();
|
||||||
|
// Envoyer le message avec les sockets (à implémenter)
|
||||||
|
socket.emit('new-message', newMessage);
|
||||||
|
console.log('Message envoyé avec succès');
|
||||||
|
messageText = '';
|
||||||
|
}else{
|
||||||
|
console.log('Erreur lors de l\'envoi du message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isLoading = false;
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
|
async function loadMoreMessages() {
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
const previousMessages = $messagesStore;
|
||||||
|
|
||||||
|
let newMessages = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculer la page à charger en fonction du nombre total de messages existants
|
||||||
|
const totalMessages = $messagesStore.length;
|
||||||
|
const pageToLoad = Math.floor(totalMessages / limit) + 1;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/channels/${data.channelId}/messages?page=${pageToLoad}&limit=${limit}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
newMessages = await response.json();
|
||||||
|
|
||||||
|
if (newMessages.messages.length <= 0) {
|
||||||
|
console.log("Pas d'autres anciens messages");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Éviter les doublons en filtrant les messages déjà présents
|
||||||
|
const existingMessageIds = new Set($messagesStore.map((msg) => msg.id));
|
||||||
|
const filteredMessages = newMessages.messages.filter(
|
||||||
|
(msg) => !existingMessageIds.has(msg.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredMessages.length > 0) {
|
||||||
|
$messagesStore = [...filteredMessages, ...$messagesStore]; // Ajouter les nouveaux messages en haut
|
||||||
|
console.log(`${filteredMessages.length} nouveaux messages ajoutés`);
|
||||||
|
} else {
|
||||||
|
console.log("Aucun nouveau message à ajouter (tous déjà chargés)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Erreur lors du chargement des anciens messages");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur réseau lors du chargement des messages:", error);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
await tick();
|
||||||
|
const filteredNewMessages = newMessages.messages.filter((msg) => {
|
||||||
|
return !previousMessages.some((m) => m.id === msg.id);
|
||||||
|
});
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: filteredNewMessages.length*300,
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (scrollContainer) {
|
||||||
|
// Détection quand on est en haut du scroll
|
||||||
|
if (scrollContainer.scrollTop <= 0 && !isLoading) {
|
||||||
|
loadMoreMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEnter(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
await sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopWritingTimeout;
|
||||||
|
|
||||||
|
function handleWriting() {
|
||||||
|
clearTimeout(stopWritingTimeout);
|
||||||
|
socket.emit('writing', { userId: data.userId, channelId: data.channelId });
|
||||||
|
stopWritingTimeout = setTimeout(() => {
|
||||||
|
handleStopWriting();
|
||||||
|
}, 2000); // Attendre 2 secondes d'inactivité avant d'émettre stop-writing
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStopWriting() {
|
||||||
|
socket.emit('stop-writing', { userId: data.userId, channelId: data.channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom(retries = 20) {
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const attemptScroll = () => {
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Protéger l'utilisation de requestAnimationFrame
|
||||||
|
if (typeof window !== 'undefined' && typeof requestAnimationFrame === 'function') {
|
||||||
|
attemptScroll();
|
||||||
|
|
||||||
|
if (retries > 0) {
|
||||||
|
requestAnimationFrame(() => scrollToBottom(retries - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
socket.emit('leave-channel', { userId: data.userId, channelId: data.channelId });
|
||||||
|
socket.disconnect(); // Déconnexion propre du socket
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ecoute des événements socket
|
||||||
|
socket.on("new-message", async (message) => {
|
||||||
|
$messagesStore = [...$messagesStore, message]; // Add the new message to the store
|
||||||
|
await tick();
|
||||||
|
await scrollToBottom(); // Scroll to the bottom after the message is added
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
socket.on("load-users-channel", async (us) => {
|
||||||
|
users = us;
|
||||||
|
await tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
socket.emit('new-user-join', { user:{ ...user, socketId:socket.id, state:"En ligne" }, channelId: data.channelId });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user-writing', async (userId) => {
|
||||||
|
console.log('user-writing reçu pour userId:', userId);
|
||||||
|
|
||||||
|
// On met à jour l'état de l'utilisateur
|
||||||
|
users = users.map((u) => {
|
||||||
|
if (u.id === userId) {
|
||||||
|
// Mettre à jour le state
|
||||||
|
return { ...u, state: "Ecrit" }; // On recrée l'objet pour garantir la réactivité
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
|
||||||
|
// On recrée une nouvelle référence du tableau `users`
|
||||||
|
users = [...users]; // Cela force Svelte à détecter le changement dans la liste
|
||||||
|
|
||||||
|
console.log('Utilisateurs après mise à jour de l\'état:', users);
|
||||||
|
|
||||||
|
// Forcer une mise à jour avec tick
|
||||||
|
await tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('user-stop-writing', async (userId) => {
|
||||||
|
console.log('user-stop-writing reçu pour userId:', userId);
|
||||||
|
|
||||||
|
users = users.map((u) => {
|
||||||
|
if (u.id === userId) {
|
||||||
|
// Mettre à jour le state
|
||||||
|
return { ...u, state: "En ligne" }; // On recrée l'objet pour garantir la réactivité
|
||||||
|
}
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
|
||||||
|
users = [...users]; // Cela force Svelte à détecter le changement dans la liste
|
||||||
|
|
||||||
|
console.log('Utilisateurs après mise à jour de l\'état:', users);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
messagesStore.subscribe(async () => {
|
||||||
|
await tick();
|
||||||
|
});
|
||||||
|
|
||||||
|
let firstPageLoad = true;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await tick();
|
||||||
|
if(firstPageLoad){
|
||||||
|
firstPageLoad = false;
|
||||||
|
await scrollToBottom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full flex">
|
||||||
|
<!-- Liste des utilisateurs (colonne gauche) -->
|
||||||
|
<div class="w-1/4 bg-gray-100 border-r overflow-y-auto">
|
||||||
|
<div class="flex gap-4 px-4 mt-5">
|
||||||
|
<Button href="/chats" variant="outline" size="icon" ><ArrowLeft /></Button>
|
||||||
|
<h2 class="text-3xl font-bold">Utilisateurs</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col m-5 gap-2">
|
||||||
|
{#each users as u (u.id)}
|
||||||
|
<UserChat
|
||||||
|
user={u}
|
||||||
|
openProfileCard={() => openProfileCard(u)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat principal (colonne droite) -->
|
||||||
|
<div class="flex-1 flex flex-col h-full">
|
||||||
|
<!-- Messages -->
|
||||||
|
<div
|
||||||
|
class="m-10 flex flex-col gap-5 overflow-auto flex-grow"
|
||||||
|
bind:this={scrollContainer}
|
||||||
|
on:scroll={handleScroll}
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-indicator">Chargement...</div>
|
||||||
|
{/if}
|
||||||
|
{#if $messagesStore !== undefined && $messagesStore.length > 0}
|
||||||
|
{#each $messagesStore as message}
|
||||||
|
<Message
|
||||||
|
userId={data.userId}
|
||||||
|
message={message}
|
||||||
|
activeProfileId={activeProfileId}
|
||||||
|
setActiveProfile={setActiveProfile}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="text-center text-gray-500 mt-10">Sélectionnez un message le chat est vide.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input pour envoyer un message -->
|
||||||
|
<div class="px-10 py-5 w-full flex gap-2 border-t">
|
||||||
|
<Textarea
|
||||||
|
class="h-16 resize-none flex-grow"
|
||||||
|
placeholder="Écrivez un message..."
|
||||||
|
bind:value={messageText}
|
||||||
|
on:keypress={handleEnter}
|
||||||
|
on:input={handleWriting}
|
||||||
|
on:blur={handleStopWriting}
|
||||||
|
/>
|
||||||
|
<Button size="icon" class="h-16 w-16 bg-blue-500 hover:bg-blue-600 h-full" on:click={sendMessage}>
|
||||||
|
<PaperPlane class="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfileCard user={userChatSelected} userSessionId={data.userId} show={showProfileCard} onClose={closeProfileCard}></ProfileCard>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loading-indicator {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
.overflow-y-auto {
|
||||||
|
scroll-behavior: smooth; /* Défilement fluide */
|
||||||
|
}
|
||||||
|
</style>
|
10
src/routes/disconnect/+server.ts
Normal file
10
src/routes/disconnect/+server.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function POST({ cookies }) {
|
||||||
|
// Supprimer les cookies "token" et "UID"
|
||||||
|
cookies.delete("token", { path: '/' });
|
||||||
|
cookies.delete("UID", { path: '/' });
|
||||||
|
|
||||||
|
// Rediriger vers la page d'accueil ou la page de connexion après déconnexion
|
||||||
|
throw redirect(303, '/');
|
||||||
|
}
|
23
src/routes/user/edit/+page.server.ts
Normal file
23
src/routes/user/edit/+page.server.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
export async function load({ fetch, locals }) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Appel API ou récupération de données
|
||||||
|
const res = await fetch(`/api/users/${locals.userId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const user = await res.json();
|
||||||
|
|
||||||
|
// Retourner les données à la page sous forme de props
|
||||||
|
return {
|
||||||
|
user
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des canaux:', error);
|
||||||
|
return {
|
||||||
|
user : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
143
src/routes/user/edit/+page.svelte
Normal file
143
src/routes/user/edit/+page.svelte
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ChoosePicture from "$lib/components/ui/ChoosePicture.svelte";
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
const user = data.user;
|
||||||
|
console.log(user);
|
||||||
|
|
||||||
|
let pseudo = user.username;
|
||||||
|
let firstName = user.name;
|
||||||
|
let lastName = user.surname;
|
||||||
|
let email = user.email;
|
||||||
|
|
||||||
|
let profilePicture = user.profilePicture; // Chemin initial ou valeur null
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
let showMessage = false;
|
||||||
|
|
||||||
|
// Fonction pour valider l'email
|
||||||
|
const validateEmail = (email: string) => {
|
||||||
|
const re = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
return re.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction de soumission du formulaire
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!pseudo || !firstName || !lastName || !email) {
|
||||||
|
message = 'Veuillez remplir tous les champs.';
|
||||||
|
showMessage = true;
|
||||||
|
} else if (!validateEmail(email)) {
|
||||||
|
message = 'L\'email est invalide.';
|
||||||
|
showMessage = true;
|
||||||
|
} else {
|
||||||
|
updateUser();
|
||||||
|
message = 'Informations mises à jour avec succès!';
|
||||||
|
showMessage = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateUser() {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('username', pseudo);
|
||||||
|
formData.append('name', firstName);
|
||||||
|
formData.append('surname', lastName);
|
||||||
|
formData.append('email', email);
|
||||||
|
|
||||||
|
if (profilePicture) {
|
||||||
|
formData.append('profilePicture', profilePicture);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/users/${user.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
console.log(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-100 mg-10">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-lg">
|
||||||
|
<h2 class="text-2xl font-semibold text-center mb-6">Modifier les informations du compte</h2>
|
||||||
|
|
||||||
|
<!-- Message d'alerte -->
|
||||||
|
{#if showMessage}
|
||||||
|
<div class="bg-blue-500 text-white p-3 rounded-lg text-center mb-4">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Formulaire de modification du profil -->
|
||||||
|
<form on:submit|preventDefault={handleSubmit}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="pseudo" class="block text-sm font-semibold text-gray-700">Pseudo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="pseudo"
|
||||||
|
bind:value={pseudo}
|
||||||
|
class="w-full p-2 mt-1 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Votre pseudo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="firstName" class="block text-sm font-semibold text-gray-700">Prénom</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
bind:value={firstName}
|
||||||
|
class="w-full p-2 mt-1 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Votre prénom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="lastName" class="block text-sm font-semibold text-gray-700">Nom</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
bind:value={lastName}
|
||||||
|
class="w-full p-2 mt-1 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Votre nom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="email" class="block text-sm font-semibold text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
bind:value={email}
|
||||||
|
class="w-full p-2 mt-1 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Votre email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Intégration du composant de photo de profil -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<ChoosePicture bind:profilePicture={profilePicture} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-6 items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-blue-500 text-white px-6 py-2 rounded-md hover:bg-blue-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
Mettre à jour
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
<Button href="/chats" variant="secondary">Retour au menu principal</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
input, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
BIN
static/default.png
Normal file
BIN
static/default.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
4
static/profile-default.svg
Normal file
4
static/profile-default.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#494c4e" d="M9 0a9 9 0 0 0-9 9 8.654 8.654 0 0 0 .05.92 9 9 0 0 0 17.9 0A8.654 8.654 0 0 0 18 9a9 9 0 0 0-9-9zm5.42 13.42c-.01 0-.06.08-.07.08a6.975 6.975 0 0 1-10.7 0c-.01 0-.06-.08-.07-.08a.512.512 0 0 1-.09-.27.522.522 0 0 1 .34-.48c.74-.25 1.45-.49 1.65-.54a.16.16 0 0 1 .03-.13.49.49 0 0 1 .43-.36l1.27-.1a2.077 2.077 0 0 0-.19-.79v-.01a2.814 2.814 0 0 0-.45-.78 3.83 3.83 0 0 1-.79-2.38A3.38 3.38 0 0 1 8.88 4h.24a3.38 3.38 0 0 1 3.1 3.58 3.83 3.83 0 0 1-.79 2.38 2.814 2.814 0 0 0-.45.78v.01a2.077 2.077 0 0 0-.19.79l1.27.1a.49.49 0 0 1 .43.36.16.16 0 0 1 .03.13c.2.05.91.29 1.65.54a.49.49 0 0 1 .25.75z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 843 B |
|
@ -1,5 +1,5 @@
|
||||||
import { mdsvex } from 'mdsvex';
|
import { mdsvex } from 'mdsvex';
|
||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
@ -8,11 +8,15 @@ const config = {
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: [vitePreprocess(), mdsvex()],
|
preprocess: [vitePreprocess(), mdsvex()],
|
||||||
|
|
||||||
|
alias: {
|
||||||
|
"@/*": "./path/to/lib/*",
|
||||||
|
},
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
adapter: adapter()
|
adapter: adapter(),
|
||||||
},
|
},
|
||||||
|
|
||||||
extensions: ['.svelte', '.svx']
|
extensions: ['.svelte', '.svx']
|
||||||
|
|
|
@ -1,11 +1,64 @@
|
||||||
import type { Config } from 'tailwindcss';
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
export default {
|
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||||
|
safelist: ["dark"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {}
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border) / <alpha-value>)",
|
||||||
|
input: "hsl(var(--input) / <alpha-value>)",
|
||||||
|
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||||
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
||||||
|
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)"
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: [...fontFamily.sans]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
plugins: []
|
export default config;
|
||||||
} satisfies Config;
|
|
||||||
|
|
|
@ -1,6 +1,87 @@
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { type ViteDevServer, defineConfig } from 'vite';
|
||||||
|
|
||||||
|
import { Server } from 'socket.io'
|
||||||
|
|
||||||
|
function isView(obj) {
|
||||||
|
return obj instanceof DataView || (obj && obj.buffer instanceof ArrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webSocketServer = {
|
||||||
|
name: 'webSocketServer',
|
||||||
|
configureServer(server: ViteDevServer) {
|
||||||
|
if (!server.httpServer) return
|
||||||
|
|
||||||
|
const io = new Server(server.httpServer)
|
||||||
|
|
||||||
|
let channelsUsers = {};
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('new-channel', (channel) => {
|
||||||
|
io.emit('new-channel', channel)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Écouter les messages
|
||||||
|
socket.on('new-message', (message) => {
|
||||||
|
io.emit('new-message', message); // Diffusion du message
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('new-user-join', (data) => {
|
||||||
|
const {user, channelId } = data;
|
||||||
|
if (!channelsUsers[channelId]) {
|
||||||
|
channelsUsers[channelId] = [];
|
||||||
|
}
|
||||||
|
if (!channelsUsers[channelId].find((u) => u.id === user.id)) {
|
||||||
|
// Ajouter l'utilisateur à la liste des utilisateurs du canal avec son socketId
|
||||||
|
channelsUsers[channelId].push(user);
|
||||||
|
}
|
||||||
|
socket.join(`channel:${channelId}`);
|
||||||
|
io.to(`channel:${channelId}`).emit('load-users-channel', channelsUsers[channelId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('leave-channel', (data) => {
|
||||||
|
const { userId, channelId } = data;
|
||||||
|
|
||||||
|
if (channelsUsers[channelId]) {
|
||||||
|
// Supprimez l'utilisateur du canal
|
||||||
|
channelsUsers[channelId] = channelsUsers[channelId].filter((u) => u.id !== userId);
|
||||||
|
|
||||||
|
io.to(`channel:${channelId}`).emit('load-users-channel', channelsUsers[channelId]);
|
||||||
|
console.log(`Utilisateur ${userId} a quitté le canal ${channelId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Déconnexion du client');
|
||||||
|
for (const channelId in channelsUsers) {
|
||||||
|
channelsUsers[channelId] = channelsUsers[channelId].filter((u) => u.socketId !== socket.id);
|
||||||
|
io.to(`channel:${channelId}`).emit('load-users-channel', channelsUsers[channelId]);
|
||||||
|
}
|
||||||
|
console.log('Utilisateurs connectés:', channelsUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('writing', (data) => {
|
||||||
|
const { userId, channelId } = data;
|
||||||
|
const us = channelsUsers[channelId]?.find((u) => u.id === userId);
|
||||||
|
if (us) {
|
||||||
|
us.state = "Ecrit";
|
||||||
|
io.to(`channel:${channelId}`).emit('user-writing', userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('stop-writing', (data) => {
|
||||||
|
const { userId, channelId } = data;
|
||||||
|
const us = channelsUsers[channelId]?.find((u) => u.id === userId);
|
||||||
|
if (us) {
|
||||||
|
us.state = "En ligne";
|
||||||
|
io.to(`channel:${channelId}`).emit('user-stop-writing', userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [sveltekit(), webSocketServer]
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue