diff options
| author | Martial Simon <msimon_fr@hotmail.com> | 2025-09-15 01:08:27 +0200 |
|---|---|---|
| committer | Martial Simon <msimon_fr@hotmail.com> | 2025-09-15 01:08:27 +0200 |
| commit | c9b6b9a5ca082fe7c1b6f58d7713f785a9eb6a5c (patch) | |
| tree | 3e4f42f93c7ae89a364e4d51fff6e5cec4e55fa9 /rushs/eplace/src | |
add: graphs et rushs
Diffstat (limited to 'rushs/eplace/src')
27 files changed, 2434 insertions, 0 deletions
diff --git a/rushs/eplace/src/components/debug.html b/rushs/eplace/src/components/debug.html new file mode 100644 index 0000000..750906d --- /dev/null +++ b/rushs/eplace/src/components/debug.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="stylesheet" type="text/css" media="screen" href="index.css" /> +</head> + +<body> + <div class="container"> + <h1>Local storage</h1> + + <h3>Token</h3> + <p id="token" class="token-text"> + {{ token }} + </p> + <h3>Refresh Token</h3> + <p id="refresh_token" class="token-text"> + {{ refresh_token }} + </p> + + <button id="errorBtn">Generate an error response</button> + <button id="expiredTokenBtn">Generate an expired token response</button> + <button id="deleteTokenBtn">Delete token</button> + <button id="deleteRefreshTokenBtn">Delete refresh token</button> + </div> +</body> + +</html> diff --git a/rushs/eplace/src/components/notifications/index.html b/rushs/eplace/src/components/notifications/index.html new file mode 100644 index 0000000..c211a39 --- /dev/null +++ b/rushs/eplace/src/components/notifications/index.html @@ -0,0 +1,12 @@ +<div class="Alert"> + <div class="Icon"> + <i class="fa {{icon_classes}}"></i> + </div> + <div class="AlertBody"> + <span class="AlertTitle">{{title}}</span> + <span class="AlertContent">{{content}}</span> + </div> + <span class="AlertClose"> + <i class="fa fa-times-circle"></i> + </span> +</div> diff --git a/rushs/eplace/src/components/rooms/index.html b/rushs/eplace/src/components/rooms/index.html new file mode 100644 index 0000000..3d9f20e --- /dev/null +++ b/rushs/eplace/src/components/rooms/index.html @@ -0,0 +1,10 @@ +<button class="Room"> + <img src="{{icon_url}}" class="Avatar" /> + <div class="TextContainer"> + <div class="Name"> + <i class="RoomPrivacy {{privacy_icon}}"></i> + <span>{{name}}</span> + </div> + <span class="RoomOwner">owner: {{owner_login}}</span> + </div> +</button> diff --git a/rushs/eplace/src/components/rooms/message.html b/rushs/eplace/src/components/rooms/message.html new file mode 100644 index 0000000..1c35e20 --- /dev/null +++ b/rushs/eplace/src/components/rooms/message.html @@ -0,0 +1,8 @@ +<div class="ChatMessage"> + <div class="MessageHeader"> + <img src="{{avatar_url}}" class="Avatar" /> + <span class="Login">{{message_author}}</span> + <span class="Time">{{sent_at}}</span> + </div> + <span class="MessageContent">{{message_content}}</span> +</div> diff --git a/rushs/eplace/src/components/rooms/upsert.html b/rushs/eplace/src/components/rooms/upsert.html new file mode 100644 index 0000000..8b02b69 --- /dev/null +++ b/rushs/eplace/src/components/rooms/upsert.html @@ -0,0 +1,70 @@ +<div class="FormOverlay"> + <form class="StylisedForm" id="room-upsert-form"> + <div class="FormHeader"> + <h2 class="FormTitle">{{form_title}}</h2> + </div> + <div class="FormItem"> + <label for="name" class="FormLabel">Name</label> + <input + type="text" + class="FormInput" + id="name" + name="name" + placeholder="Enter name" + /> + </div> + <div class="FormItem"> + <label for="description" class="FormLabel">Description</label> + <input + type="text" + class="FormInput" + id="description" + name="description" + placeholder="Enter description" + /> + </div> + <div class="FormItem"> + <label for="icon-url" class="FormLabel">Icon URL</label> + <input + type="" + class="FormInput" + id="icon-url" + name="icon-url" + placeholder="Enter icon URL" + /> + </div> + <div class="FormItem"> + <label for="whitelist" class="FormLabel">Students Whitelist</label> + <input + type="text" + class="FormInput" + id="whitelist" + name="whitelist" + placeholder="Enter student's logins or UIDs, separated by commas (private rooms only)" + /> + </div> + <div class="FormItem"> + <label for="blacklist" class="FormLabel">Students Blacklist</label> + <input + type="text" + class="FormInput" + id="blacklist" + name="blacklist" + placeholder="Enter student's logins or UIDs, separated by commas (public rooms only)" + /> + </div> + <div class="FormItem"> + <label for="is-public" class="FormLabel">Is Public</label> + <input + type="checkbox" + class="FormInput" + id="is-public" + name="is-public" + /> + </div> + <div class="FormButtons"> + <button type="button" id="close-modal">Cancel</button> + <button type="submit">Submit</button> + </div> + </form> +</div> diff --git a/rushs/eplace/src/components/rooms/user-event.html b/rushs/eplace/src/components/rooms/user-event.html new file mode 100644 index 0000000..3aec718 --- /dev/null +++ b/rushs/eplace/src/components/rooms/user-event.html @@ -0,0 +1,3 @@ +<div class="ChatUserEvent"> + <span class="MessageContent">{{message_content}}</span> +</div> diff --git a/rushs/eplace/src/components/students/update.html b/rushs/eplace/src/components/students/update.html new file mode 100644 index 0000000..912526e --- /dev/null +++ b/rushs/eplace/src/components/students/update.html @@ -0,0 +1,31 @@ +<div class="FormOverlay"> + <form class="StylisedForm" id="student-update-form"> + <div class="FormHeader"> + <h2 class="FormTitle">Update Profile</h2> + </div> + <div class="FormItem"> + <label for="avatar-url" class="FormLabel">Avatar URL</label> + <input + type="" + class="FormInput" + id="avatar-url" + name="avatar-url" + placeholder="Enter avatar URL" + /> + </div> + <div class="FormItem"> + <label for="quote" class="FormLabel">Quote</label> + <input + type="text" + class="FormInput" + id="quote" + name="quote" + placeholder="Enter a quote" + /> + </div> + <div class="FormButtons"> + <button type="button" id="close-modal">Cancel</button> + <button type="submit">Submit</button> + </div> + </form> +</div> diff --git a/rushs/eplace/src/pages/complete/epita/index.html b/rushs/eplace/src/pages/complete/epita/index.html new file mode 100644 index 0000000..a6ebe75 --- /dev/null +++ b/rushs/eplace/src/pages/complete/epita/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <title>E/PLACE</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="/favicon.ico" /> + <script type="module" src="index.js"></script> + </head> + <body></body> +</html> diff --git a/rushs/eplace/src/pages/complete/epita/index.js b/rushs/eplace/src/pages/complete/epita/index.js new file mode 100644 index 0000000..ced46cf --- /dev/null +++ b/rushs/eplace/src/pages/complete/epita/index.js @@ -0,0 +1,53 @@ +// FIXME: This file should handle the auth redirection + +// Get the code from the URL parameters and redirect to the relevant page +const params = new URLSearchParams(window.location.search); + +const code = params.get("code"); +const form = new FormData(); + +form.append("client_id", import.meta.env.VITE_CLIENT_ID); +form.append("redirect_uri", `${import.meta.env.VITE_URL}/complete/epita/`); +form.append("grant_type", "authorization_code"); +form.append("code", code); +const config = { + method: "POST", + body: form, +}; + +export async function getToken() { + try { + const res = await fetch( + `${import.meta.env.VITE_URL}/auth-api/token`, + config, + ); + const response = await res.json(); + + const token = response.id_token; + + localStorage.setItem("token", token); + localStorage.setItem("refresh_token", response.refresh_token); + window.location.replace(import.meta.env.VITE_URL); + return true; + } catch (error) { + console.log(error); + return false; + } +} + +if (!code) { + const authQueryParams = { + client_id: import.meta.env.VITE_CLIENT_ID, + scope: "epita profile picture", + redirect_uri: `${import.meta.env.VITE_URL}/complete/epita/`, + response_type: "code", + }; + const url = new URL( + `?client_id=${authQueryParams.client_id}&scope=${authQueryParams.scope}&redirect_uri=${authQueryParams.redirect_uri}&response_type=${authQueryParams.response_type}`, + `${import.meta.env.VITE_AUTH_URL}/authorize`, + ); + + window.location.replace(url); +} else { + getToken(); +} diff --git a/rushs/eplace/src/pages/debug.js b/rushs/eplace/src/pages/debug.js new file mode 100644 index 0000000..e2da5f4 --- /dev/null +++ b/rushs/eplace/src/pages/debug.js @@ -0,0 +1,34 @@ +import $ from "jquery"; +import debugHtml from "../components/debug.html"; + +function refreshLocalStorage() { + $("#token").text(localStorage.getItem("token") ?? "N/A"); + $("#refresh_token").text(localStorage.getItem("refresh_token") ?? "N/A"); +} + +if (import.meta.env.MODE === "debug") { + $.get(debugHtml, function (response) { + $("body").html(response); + refreshLocalStorage(); + }).fail(function (xhr, status, error) { + console.error("Error fetching debug HTML:", error); + }); + + $(document).on("click", "#errorBtn", function () { + // Make a call to VITE_URL/tests/error using your own api call function + }); + + $(document).on("click", "#expiredTokenBtn", function () { + // Make a call to VITE_URL/tests/expired using your own api call function + }); + + $(document).on("click", "#deleteTokenBtn", function () { + localStorage.removeItem("token"); + refreshLocalStorage(); + }); + + $(document).on("click", "#deleteRefreshTokenBtn", function () { + localStorage.removeItem("refresh_token"); + refreshLocalStorage(); + }); +} diff --git a/rushs/eplace/src/pages/index.css b/rushs/eplace/src/pages/index.css new file mode 100644 index 0000000..6548524 --- /dev/null +++ b/rushs/eplace/src/pages/index.css @@ -0,0 +1,78 @@ +@import url(http://fonts.googleapis.com/css?family=Barlow:800); +@import url(http://fonts.googleapis.com/css?family=Montserrat:400,700); + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif, Barlow; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; + + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + justify-content: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; + margin: 0; +} + +h2 { + margin: 0; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} +input { + border: none; +} +.token-text { + max-width: 10ch; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/rushs/eplace/src/pages/index.html b/rushs/eplace/src/pages/index.html new file mode 100644 index 0000000..d52232b --- /dev/null +++ b/rushs/eplace/src/pages/index.html @@ -0,0 +1,173 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <title>E/PLACE</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="icon" href="/favicon.ico" /> + <link rel="stylesheet" type="text/css" media="screen" href="index.css" /> + <link rel="stylesheet/less" type="text/css" href="styles.less" /> + <link + href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" + rel="stylesheet" + /> + <script src="https://cdn.jsdelivr.net/npm/less"></script> + <script type="module" src="index.js"></script> + </head> + <body> + <div class="App"> + <div class="AlertContainer" id="alert-container"></div> + <div class="Container" id="container"> + <div class="RoomList Hidden" id="left-container"> + <div class="Header"> + <h1 class="Title">E/PLACE</h1> + <button class="CloseButton" id="close-left"> + <i class="fa-solid fa-backward"></i> + </button> + </div> + <div class="ListContainer"> + <div class="ListSearchBar"> + <div class="InputContainer"> + <input + aria-placeholder="Search a room..." + placeholder="Search a room..." + class="Input" + id="search-input" + /> + <div class="Divider"></div> + <button class="RoomButton" id="create-room"> + <i class="fa-solid fa-square-plus"></i> + </button> + <button class="RoomButton" id="refresh-rooms"> + <i class="fa-solid fa-rotate"></i> + </button> + </div> + <div class="FilterContainer"> + <span class="FilterText">Filters:</span> + <div class="FilterHeader"> + <button class="Filter" id="filter-name"> + <span>Name</span> + <i class="fa-solid fa-sort-down"></i> + </button> + <button class="Filter" id="filter-owner"> + <span>Owned by you</span> + <i class="fa-solid fa-plus"></i> + </button> + <button class="Filter" id="filter-public"> + <span>Public</span> + <i class="fa-solid fa-minus"></i> + </button> + <button class="Filter" id="filter-private"> + <span>Private</span> + <i class="fa-solid fa-minus"></i> + </button> + </div> + </div> + </div> + <div class="RoomsContainer" id="rooms-container"></div> + </div> + <div class="StudentProfile"> + <img src="" class="Avatar" id="profile-info-avatar" /> + <div class="TextContainer"> + <span class="Login" id="profile-info-login"></span> + <span class="Quote" id="profile-info-quote"></span> + </div> + <button class="ModifyProfileButton" id="profile-update"> + <i class="fa-solid fa-pencil"></i> + </button> + </div> + </div> + <div class="RoomCanvas"> + <div class="Header"> + <div class="TextContainer"> + <h2 class="Title" id="room-name">{{room_name}}</h2> + <span class="Description" id="room-description" + >{{room_description}}</span + > + </div> + <div class="HeaderDivider"></div> + <div class="ButtonContainer"> + <button class="RoomButton" id="edit-room"> + <i class="fa-solid fa-pencil"></i> + </button> + <button class="RoomButton" id="delete-room"> + <i class="fa-solid fa-trash-can"></i> + </button> + <button class="ReportButton">Report Abuse</button> + </div> + </div> + <div class="CanvasContainer" id="canvas-container"> + <canvas class="Canvas" id="canvas"></canvas> + <img class="Selector" id="selector" src="/selector.svg" /> + <div class="Tooltip" id="tooltip"> + <div class="Header"> + <div class="PlacedByInfo"> + <img + src="{{student_avatar}}" + class="Avatar" + id="tooltip-info-avatar" + /> + <div class="Profile"> + <span class="Login" id="tooltip-info-login" + >{{student_login}}</span + > + <span class="Quote" id="tooltip-info-quote" + >{{student_quote}}</span + > + </div> + </div> + <div class="TextContainer"> + <span id="tooltip-date">{{date_placed}}</span> + <span id="tooltip-time">{{time_placed}}</span> + </div> + </div> + <div class="ButtonContainer"> + <button class="ColorPicker" id="color-picker"> + <i class="fas fa-eye-dropper"></i> + </button> + <button class="PlaceButton" id="color-place-button"> + PLACE + </button> + </div> + </div> + </div> + <div class="PositionTooltip" id="position-tooltip"> + <span>X=0</span> + <span>Y=0</span> + </div> + <div class="ColorWheelContainer" id="color-wheel-container"> + <div class="ColorWheel" id="color-wheel"></div> + </div> + </div> + <div class="RoomChat" id="right-container"> + <div class="ChatContainer"> + <div class="Header"> + <button class="CloseButton" id="close-right"> + <i class="fa-solid fa-forward"></i> + </button> + </div> + <div class="ChatMessageContainer" id="chat-message-container"></div> + <form + class="InputContainer" + id="chat-input-form" + autocomplete="off" + > + <input + aria-placeholder="Type your message here..." + placeholder="Type your message here..." + class="Input" + id="message-content" + name="message-content" + autocomplete="off" + /> + <div class="ChatTimeout"> + <i class="fa-solid fa-stopwatch"></i> + </div> + </form> + </div> + </div> + </div> + </div> + </body> +</html> diff --git a/rushs/eplace/src/pages/index.js b/rushs/eplace/src/pages/index.js new file mode 100644 index 0000000..59cdb91 --- /dev/null +++ b/rushs/eplace/src/pages/index.js @@ -0,0 +1,10 @@ +// FIXME: This is the entry point of the application, write your code here + +import { calculateLayout } from "./utils"; +import "./debug"; +import { initSocket } from "../utils/streams"; + +// Initialize the layout +calculateLayout(); + +initSocket(); diff --git a/rushs/eplace/src/pages/styles.less b/rushs/eplace/src/pages/styles.less new file mode 100644 index 0000000..dd8ae95 --- /dev/null +++ b/rushs/eplace/src/pages/styles.less @@ -0,0 +1,905 @@ +@base: rgba(16, 18, 60, 0.95); +@dark: #05061a; +@light: #f0f0f0; +@accent: #ff4603; + +.App { + background-color: @dark; + min-height: 100vh; + margin: 0 auto; + overflow: hidden; + padding: 0rem; + position: relative; + text-align: center; + width: 100vw; +} + +.AlertContainer { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + justify-content: flex-end; + gap: 1rem; + padding: 2rem; + z-index: 3; + pointer-events: none; + + .Alert { + position: relative; + background-color: #fff; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem; + width: 100%; + pointer-events: all; + + box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px; + + .Icon { + font-size: 28px; + } + + .AlertBody { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + margin: 0.5rem 2rem 0.5rem 0; + text-align: left; + font-family: Barlow; + color: @dark; + + .AlertTitle { + font-size: 1rem; + font-weight: 800; + text-transform: uppercase; + } + .AlertContent { + font-size: 0.75rem; + opacity: 50%; + } + } + + .AlertClose { + position: absolute; + top: 0.5rem; + right: 0.5rem; + font-size: 1em; + color: @dark; + } + } + + .AlertSuccess { + border-left: 5px solid #49d761; + + .Icon { + color: #49d761; + } + } + .AlertWarning { + border-left: 5px solid #ff9f1c; + + .Icon { + color: #ff9f1c; + } + } + .AlertError { + border-left: 5px solid #fd2020; + + .Icon { + color: #fd2020; + } + } +} + +.Container { + column-gap: 2em; + display: grid; + flex-direction: row; + grid-auto-flow: row dense; + // Fully openned sidebars layout + // -> grid-template-columns: 1fr 2.5fr 1fr; + // Closed sidebars layout + grid-template-columns: 0fr 2.5fr 0fr; + height: calc(100vh - 4rem); + overflow: hidden; + padding: 2rem; + + transition: all 0.5s ease-in-out; +} + +.RoomList { + background-color: @base; + border-radius: 0.5rem; + display: grid; + flex-grow: 0; + gap: 0.5em 0em; + grid-template-rows: 1fr 8.25fr 0.75fr; + overflow: hidden; + padding: 1.5rem; + z-index: 1; + // Fully openned sidebar + // -> opacity: 1; + // Closed sidebar + opacity: 0; + + transition: opacity 0.5s ease-in-out; + + .Header { + display: flex; + align-items: center; + justify-content: space-between; + overflow: hidden; + + .Title { + color: @light; + font-family: Barlow; + font-size: 3rem; + font-weight: 800; + grid-row-start: 1; + overflow: hidden; + text-align: start; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + } + + .ListContainer { + display: flex; + flex-direction: column; + gap: 1em; + margin: 1rem 0rem; + overflow: auto; + padding: 0rem; + } + + .ListSearchBar { + display: flex; + flex-direction: column; + gap: 0.5em 0em; + + .FilterContainer { + background-color: rgba(black, 0.5); + align-items: center; + display: flex; + font-family: Barlow; + font-weight: 800; + font-size: 0.75rem; + gap: 0em 1em; + overflow: hidden; + text-align: start; + white-space: nowrap; + padding: 0.5rem; + border-radius: 0.5rem; + + .FilterText { + margin-left: 0.5rem; + opacity: 0.5; + font-size: 0.8rem; + } + + .FilterHeader { + display: flex; + font-family: Barlow; + font-weight: 800; + overflow: scroll; + scrollbar-width: none; + text-align: start; + gap: 0.5em; + + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + + .Filter { + background-color: rgba(@light, 0.25); + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5em; + padding: 0.25rem 0.5rem; + font-size: 0.65rem; + } + } + } + } + + .RoomsContainer { + display: flex; + flex-direction: column; + gap: 0.5rem; + grid-row-start: 2; + overflow: scroll; + + .Room { + background-color: rgba(black, 0.25); + border-radius: 0.5rem; + display: flex; + gap: 1em; + align-items: center; + padding: 0.5rem; + + &:disabled { + border: @accent 0.15rem solid; + cursor: not-allowed; + } + + .Avatar { + height: 4rem; + width: 4rem; + object-fit: cover; + aspect-ratio: 1/1; + } + + .TextContainer { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5em; + color: @light; + overflow: hidden; + + .Name { + font-family: Barlow; + font-size: 1rem; + font-weight: 800; + text-align: start; + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .RoomPrivacy { + margin-right: 0.25rem; + } + } + + .RoomOwner { + font-family: Montserrat; + font-size: 0.65rem; + font-weight: 700; + text-align: start; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + + .ListRoomDivider { + background-color: rgba(@light, 0.25); + height: 0.1rem; + margin: 0.5rem 0rem; + } + } + + .StudentProfile { + align-items: center; + background-color: @light; + border-radius: 5rem; + column-gap: 0.5em; + display: flex; + flex-direction: row; + grid-row-start: 3; + overflow: hidden; + padding: 0.5rem; + + .Avatar { + border-radius: 100%; + max-height: 3rem; + max-width: 3rem; + object-fit: cover; + aspect-ratio: 1/1; + } + + .TextContainer { + display: flex; + flex-direction: column; + justify-content: center; + margin-right: 0.5rem; + overflow: hidden; + color: black; + text-align: start; + overflow: hidden; + + .Login { + font-family: Barlow; + font-size: 1rem; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .Quote { + font-family: Montserrat; + font-size: 0.7rem; + font-weight: 700; + opacity: 65%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .ModifyProfileButton { + background-color: transparent; + color: @dark; + padding: 0.25rem; + margin-left: auto; + margin-right: 0.5rem; + border-radius: 100%; + } + } +} + +.RoomCanvas { + display: grid; + flex-direction: column; + flex-grow: 0; + grid-template-rows: 0.25fr 9.75fr; + min-width: 50vw; + row-gap: 0em; + overflow: hidden; + + .Header { + background-color: @base; + border-radius: 0.5rem; + align-items: center; + justify-content: center; + display: flex; + flex-direction: row; + gap: 1.5rem; + grid-row-start: 1; + padding: 0.5rem; + z-index: 1; + + .TextContainer { + display: flex; + flex-direction: column; + justify-content: center; + text-align: end; + + .Title { + color: @light; + font-family: Barlow; + font-size: 2rem; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + + .Description { + // Start with the description hidden. + // It needs to be visible if the room has a description. + display: none; + position: relative; + top: -0.25rem; + + color: @light; + font-family: Montserrat; + font-size: 0.5rem; + font-weight: 700; + opacity: 0.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .HeaderDivider { + background-color: rgba(@light, 0.25); + height: 100%; + margin: 0.5rem 0rem; + width: 0.1rem; + } + + .ButtonContainer { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + + .ReportButton { + background-color: @accent; + border-radius: 0.5rem; + border: 0px solid transparent; + color: @light; + display: block; + font-family: Montserrat; + font-size: 0.75rem; + font-weight: 700; + height: auto; + padding: 0.35rem 0.75rem; + text-transform: uppercase; + } + } + } + + .CanvasContainer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + grid-row-start: 2; + overflow: hidden; + z-index: 0; + + .Canvas { + transform: translate(0, 0) scale(2.5); + } + + .Selector { + position: absolute; + inset: 0; + width: 1px; + height: 1px; + margin: auto; + pointer-events: none; + } + + .Tooltip { + // The tooltip is a flexbox + // -> display: flex; + // Start with the tooltip hidden + display: none; + + position: absolute; + top: 50%; + left: 50%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + transform: translate(-50%, -150%); + background-color: @base; + border-radius: 0.5rem; + border: @accent 0.15rem solid; + padding: 0.5rem; + + .Header { + display: flex; + gap: 0.75rem; + + .PlacedByInfo { + position: relative; + display: inline-block; + height: 3rem; + width: 3rem; + + .Avatar { + border-radius: 100%; + width: 100%; + height: 100%; + object-fit: cover; + aspect-ratio: 1/1; + } + + .Profile { + visibility: hidden; + background-color: @dark; + color: @light; + text-align: center; + padding: 0.5rem; + border-radius: 0.5rem; + + bottom: 100%; + left: 50%; + transform: translate(-50%, 0); + position: absolute; + z-index: 1; + + &::after { + content: " "; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: @dark transparent transparent transparent; + } + + .Login { + font-family: Barlow; + font-size: 0.8rem; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .Quote { + font-family: Montserrat; + font-size: 0.7rem; + font-weight: 700; + opacity: 0.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &:hover .Profile { + visibility: visible; + } + } + + .TextContainer { + display: flex; + flex-direction: column; + justify-content: center; + font-family: Montserrat; + font-size: 0.75rem; + font-weight: 700; + text-align: left; + } + } + + .ButtonContainer { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + width: 100%; + font-family: Montserrat; + font-size: 0.75rem; + font-weight: 700; + + .ColorPicker { + padding: 0.2rem; + outline: none; + color: white; + border: white 0.1rem solid; + } + + .PlaceButton { + padding: 0.2rem; + border: @accent 0.1rem solid; + border-radius: 0.5rem; + outline: none; + font-family: Barlow; + font-size: 0.75rem; + font-weight: 800; + + &:active { + background-color: @accent; + color: @light; + } + + &:disabled { + background-color: @dark; + color: @accent; + } + } + } + } + } + + .PositionTooltip { + background-color: rgba(@dark, 0.25); + padding: 0.5rem; + margin: 0.5rem auto auto 0; + border-radius: 0.5rem; + font-family: Montserrat; + font-weight: 700; + z-index: 2; + } + + .ColorWheelContainer { + // The color wheel is a flexbox + // -> display: block; + // Start with the color wheel hidden + display: none; + + background-color: @base; + margin: auto 0 0 auto; + padding: 0.5rem; + max-height: calc(100% - 12rem); + overflow: scroll; + border-radius: 1rem; + border: @accent 0.15rem solid; + z-index: 1; + + min-block-size: -webkit-fill-available; + + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + + transition: all 0.5s ease-in-out; + + .ColorWheel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + margin: 0.25rem 0; + } + } +} + +.RoomChat { + display: grid; + flex-grow: 0; + grid-auto-flow: row; + grid-template-rows: 1fr 8fr; + overflow: hidden; + padding: 0rem; + z-index: 1; + // Fully openned sidebar + // -> opacity: 1; + // Closed sidebar + opacity: 0; + + transition: opacity 0.5s ease-in-out; + + .ChatContainer { + background-color: @base; + border-radius: 0.5rem; + display: grid; + gap: 1em; + grid-row-start: 2; + grid-template-rows: 0.5fr 6.75fr 0.75fr; + grid-template-columns: 1fr; + overflow: hidden; + padding: 0.8rem; + + .Header { + display: flex; + flex-direction: row-reverse; + align-items: center; + } + + .ChatMessageContainer { + display: flex; + flex-direction: column-reverse; + overflow: auto; + padding: 0rem; + row-gap: 1em; + + .ChatMessage { + color: @light; + background-color: rgba(black, 0.25); + border-radius: 0.5rem; + display: flex; + flex-direction: column; + padding: 0.5rem; + + .MessageHeader { + column-gap: 0.5em; + display: flex; + padding: 0.5rem; + overflow: hidden; + + .Avatar { + border-radius: 100%; + max-height: 2rem; + max-width: 2rem; + object-fit: cover; + aspect-ratio: 1/1; + } + + .Login { + font-family: Barlow; + font-weight: 800; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .Time { + font-family: Barlow; + margin-left: auto; + text-align: end; + } + } + } + + .ChatUserEvent { + background-color: rgba(black, 0.25); + border-radius: 0.5rem; + display: flex; + flex-direction: column; + padding: 0.25rem; + opacity: 0.5; + } + + .ChatMessageMentionned { + background-color: rgba(@accent, 0.25); + } + + .MessageContent { + font-family: Montserrat; + font-size: 0.75rem; + font-weight: 400; + overflow-wrap: break-word; + padding: 0.5rem; + text-align: start; + } + } + } +} + +.Hidden { + width: 0; + height: 0; + margin: 0; + padding: 0; +} + +.CloseButton { + background-color: @accent; + border-radius: 0.5rem; + border: 0px solid transparent; + color: @light; + display: block; + font-family: Montserrat; + font-size: 0.75rem; + font-weight: 700; + max-height: 2rem; + padding: 0.5rem 0.75rem; + text-transform: uppercase; +} + +.InputContainer { + background-color: rgba(black, 0.5); + border-radius: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + overflow: hidden; + + .Divider { + background-color: @light; + height: 2rem; + width: 0.1rem; + opacity: 0.75; + } +} + +.RoomButton { + background-color: rgba(@light, 0.25); + padding: 0.35rem 0.5rem; + font-size: 0.75rem; +} + +.ChatTimeout { + --fill-percent: 0; + + border-radius: 50%; + padding: 0.25rem 0.5rem; + + background: radial-gradient( + closest-side, + @dark 80%, + transparent 0 99.9%, + @dark 0 + ), + conic-gradient(@accent calc(var(--fill-percent) * 1%), @dark 0); +} + +.Input { + background-color: rgba(black, 0); + color: @light; + flex-grow: 1; + font-family: Montserrat; + font-size: 0.75rem; + font-weight: 700; + opacity: 1; + padding: 0.5rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.FormOverlay { + position: absolute; + inset: 0; + background-color: rgba(@dark, 0.95); + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + + .StylisedForm { + background-color: @base; + padding: 2rem 1rem; + border: 0.25rem solid @accent; + border-radius: 0.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + justify-content: center; + font-family: Barlow; + min-width: 65vw; + max-width: 90vw; + color: @light; + + .FormHeader { + display: flex; + margin-bottom: 2rem; + + .FormTitle { + font-size: 1.5rem; + font-weight: 800; + text-align: center; + } + } + + .FormItem { + display: grid; + grid-template-columns: 1fr 6fr; + width: 100%; + gap: 1rem; + align-items: center; + justify-content: space-between; + + .FormLabel { + font-size: 1rem; + font-weight: 800; + text-align: end; + } + + .FormInput { + color: @light; + grid-column: 2; + padding: 0.5rem; + border-radius: 0.25rem; + font-family: Montserrat; + background-color: @dark; + } + + .FormInput[type="checkbox"] { + width: auto; + margin-right: auto; + } + } + + .FormButtons { + display: flex; + margin-top: 1rem; + gap: 0.5rem; + justify-content: space-around; + + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + + [type="submit"] { + background-color: #ff4603; + } + + button { + background-color: @dark; + color: @light; + border: 0.1rem solid #ff4603; + + &:hover { + border: 0.1rem solid @light; + } + } + } + } +} diff --git a/rushs/eplace/src/pages/utils.js b/rushs/eplace/src/pages/utils.js new file mode 100644 index 0000000..f8b1008 --- /dev/null +++ b/rushs/eplace/src/pages/utils.js @@ -0,0 +1,95 @@ +/** + * Global variables + */ +let [leftTimer, rightTimer] = [null, null]; +let [leftSize, rightSize] = [ + localStorage.getItem("leftSize") ?? 0, + localStorage.getItem("rightSize") ?? 0, +]; + +const parentContainer = document.getElementById("container"); + +const leftContainer = document.getElementById("left-container"); +const closeLeftButton = document.getElementById("close-left"); +const rightContainer = document.getElementById("right-container"); +const closeRightButton = document.getElementById("close-right"); + +leftContainer.classList.toggle("Hidden", !leftSize); + +/** + * Calculate the layout of the home page + */ +export const calculateLayout = () => { + const parentContainerSize = `${4.5 - leftSize - rightSize}fr`; + + // left and right are reversed because of the grid layout + parentContainer.style.gridTemplateColumns = `${leftSize}fr ${parentContainerSize} ${rightSize}fr`; + leftContainer.style.opacity = leftSize; + rightContainer.style.opacity = rightSize; +}; + +closeLeftButton.addEventListener("click", () => { + leftSize = 1 - leftSize; + localStorage.setItem("leftSize", leftSize); + + calculateLayout(); + setTimeout( + () => { + leftContainer.classList.toggle("Hidden", true); + }, + leftSize ? 0 : 300, + ); +}); + +closeRightButton.addEventListener("click", () => { + rightSize = 1 - rightSize; + localStorage.setItem("rightSize", rightSize); + + calculateLayout(); + setTimeout( + () => { + rightContainer.classList.toggle("Hidden", true); + }, + rightSize ? 0 : 300, + ); +}); + +// If the mouse holds on the left side of the screen, open the left container +document.addEventListener("mousemove", (e) => { + if (e.clientX < 10) { + if (!leftTimer) { + leftTimer = setTimeout(() => { + leftSize = 1; + localStorage.setItem("leftSize", leftSize); + + calculateLayout(); + setTimeout(() => { + leftContainer.classList.toggle("Hidden", false); + }, 300); + }, 200); + } + } else { + clearTimeout(leftTimer); + leftTimer = null; + } +}); + +// If the mouse holds on the right side of the screen, open the right container +document.addEventListener("mousemove", (e) => { + if (e.clientX > window.innerWidth - 10) { + if (!rightTimer) { + rightTimer = setTimeout(() => { + rightSize = 1; + localStorage.setItem("rightSize", rightSize); + + calculateLayout(); + setTimeout(() => { + rightContainer.classList.toggle("Hidden", false); + }, 300); + }, 200); + } + } else { + clearTimeout(rightTimer); + rightTimer = null; + } +}); diff --git a/rushs/eplace/src/rooms/canvas/index.js b/rushs/eplace/src/rooms/canvas/index.js new file mode 100644 index 0000000..440e0d3 --- /dev/null +++ b/rushs/eplace/src/rooms/canvas/index.js @@ -0,0 +1,96 @@ +// FIXME: This file should handle the room canvas API +// Link buttons to their respective functions +// Functions may include: + +import { getStudent } from "../../students"; +import { authedAPIRequest } from "../../utils/auth"; +import { getPlacementData } from "./utils"; + +// - getCanvas (get the canvas of a room and deserialize it) +export async function getCanvas(slug) { + const config = { + method: "get", + }; + + return authedAPIRequest(`/rooms/${slug}/canvas`, config) + .then(async (res) => { + if (!res) { + return null; + } + + const response = await res.json(); + + const pixels = response.pixels + .split("") + .map((c) => c.charCodeAt(0).toString(2).padStart(8, "0")) + .join("") + .match(/.{5}/g) + .map((b) => parseInt(b, 2)); + + return pixels; + }) + .catch((error) => { + console.log(error); + return null; + }); +} +// - subscribeToRoom (subscribe to the stream of a room) +// - getPixelInfo (get the pixel info of a room) +export async function getPixelInfo() { + const info = getPlacementData(); + + const response = await authedAPIRequest( + `/rooms/epi-place/canvas/pixels?posX=${info.posX}&posY=${info.posY}`, + { method: "get" }, + ); + + if (!response) { + return null; + } else { + return response.json(); + } +} + +// - placePixel (place a pixel in a room) +export async function placePixel() { + const info = getPlacementData(); + + const res = await authedAPIRequest(`/rooms/epi-place/canvas/pixels`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + posX: info.posX, + posY: info.posY, + color: info.color, + }), + }); + + if (!res) { + return; + } + + const response = await res.json(); + + const placeDate = new Date(response.timestamp); + const studentInfo = await getStudent(response.placedByUid); + + if (!studentInfo) { + return; + } + + document.getElementById("tooltip-time").innerHTML = + placeDate.toLocaleTimeString(); + document.getElementById("tooltip-date").innerHTML = + placeDate.toLocaleDateString(); + document + .getElementById("tooltip-info-avatar") + .setAttribute("src", studentInfo.avatarURL ?? "/default-avatar.png"); + document.getElementById("tooltip-info-quote").innerHTML = studentInfo.quote; + document.getElementById("tooltip-info-login").innerHTML = studentInfo.login; +} +document + .getElementById("color-place-button") + .addEventListener("click", placePixel); diff --git a/rushs/eplace/src/rooms/canvas/utils.js b/rushs/eplace/src/rooms/canvas/utils.js new file mode 100644 index 0000000..5737dbb --- /dev/null +++ b/rushs/eplace/src/rooms/canvas/utils.js @@ -0,0 +1,432 @@ +// This file handles the room canvas DOM manipulation +// Functions includes: +// - initCanvas (initialize the canvas) +// - renderCanvasUpdate (render a canvas update) +// - getPlacementData (get the necessary data to place a pixel) +// - toggleTooltip (toggle the tooltip and display the pixel's information) + +import $ from "jquery"; +import { getPixelInfo } from "."; +import { getStudent } from "../../students"; + +const canvasContainer = $("#canvas-container")?.[0]; +const canvas = $("#canvas")?.[0]; +const canvasCtx = canvas.getContext("2d"); +const selector = $("#selector")?.[0]; + +const positionTooltip = $("#position-tooltip")?.[0]; +const tooltip = $("#tooltip")?.[0]; +const colorPicker = $("#color-picker")?.[0]; +const colorWheelContainer = $("#color-wheel-container")?.[0]; +const colorWheel = $("#color-wheel")?.[0]; + +/** + * Global variables + */ +let board, palette, selectedColorIdx; +let animation; + +const zoomSpeed = 1 / 25; +let zoom = 2.5; + +let x, y; +let cx = 0; +let cy = 0; +let target = { x: 0, y: 0 }; +let isDrag = false; + +/** + * Returns the necessary data to place a pixel + * @returns {{color: number, posX: number, posX: number}} the data + */ +export const getPlacementData = () => ({ + color: selectedColorIdx, + posX: target.x, + posY: target.y, +}); + +/** + * Toggle the tooltip and display the pixel's information + * @param {boolean} state + */ +export const toggleTooltip = async (state = false) => { + tooltip.style.display = state ? "flex" : "none"; + + if (state) { + const pixelInfo = await getPixelInfo(); + const uid = pixelInfo.placedByUid; + const placeDate = new Date(pixelInfo.timestamp); + + if (!pixelInfo) { + throw new Error( + 'An error occured while hitting the "/rooms/epi-place/canvas/pixels" endpoint', + ); + } + + const studentInfo = await getStudent(uid); + + if (!studentInfo) { + throw new Error( + 'An error occured while hitting the "/students/:id" endpoint', + ); + } + + document.getElementById("tooltip-time").innerHTML = + placeDate.toLocaleTimeString(); + document.getElementById("tooltip-date").innerHTML = + placeDate.toLocaleDateString(); + document + .getElementById("tooltip-info-avatar") + .setAttribute( + "src", + studentInfo.avatarURL ?? "/default-avatar.png", + ); + document.getElementById("tooltip-info-quote").innerHTML = + studentInfo.quote; + document.getElementById("tooltip-info-login").innerHTML = + studentInfo.login; + // FIXME: You should implement or call a function to get the pixel's information + // and display it. Make use of target.x and target.y to get the pixel's position. + } +}; + +/** + * Calculate the target position according to the top left corner of the canvas + * @param {*} event + * @returns {x: number, y: number} the target position + */ +const calculateTarget = (event) => { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const canvasLeft = rect.left + window.pageXOffset; + const canvasTop = rect.top + window.pageYOffset; + + return { + x: Math.floor( + ((event?.pageX ?? window.innerWidth / 2) - canvasLeft) * scaleX, + ), + y: Math.floor( + ((event?.pageY ?? window.innerHeight / 2) - canvasTop) * scaleY, + ), + }; +}; + +/** + * Update the position tooltip + * @param {*} event + */ +const positionUpdate = (event) => positionDisplay(calculateTarget(event)); + +/** + * Update the position tooltip + * @param {{x: number, y: number}} target + */ +const positionDisplay = ({ x, y }) => { + positionTooltip.innerText = `X=${x} Y=${y}`; + canvas.style.transform = `translate(${cx}px, ${cy}px) scale(${zoom})`; + + // We add the canvas.width * zoom to make cx and cy positive + let selectorX = cx + canvas.width * zoom; + let selectorY = cy + canvas.height * zoom; + + // Make odd canvas align + if (canvas.width % 2 !== 0) { + selectorX += zoom / 2; + selectorY += zoom / 2; + } + + // Find the translate + selectorX %= zoom; + selectorY %= zoom; + + // Center selector on the pixel + selectorX -= zoom / 2; + selectorY -= zoom / 2; + + selector.style.transform = `translate(${selectorX}px, ${selectorY}px) scale(${zoom})`; +}; + +// Toggle the color wheel on click on the color picker +colorPicker.addEventListener("click", () => { + const state = colorWheelContainer.style.display; + + colorWheelContainer.style.display = + !state || state === "none" ? "block" : "none"; +}); + +/** + * Transform #RRGGBB to 0xBBGGRRAA + * @param {string} hex + * @returns {number} the 32 bits color + */ +const transformHexTo32Bits = (hex) => { + const reverse = hex.substring(1).match(/.{2}/g).reverse().join(""); + + return parseInt(`0xFF${reverse}`, 16); +}; + +/** + * Render the canvas + * @param {number[]} pixels + * @param {string[]} colors + */ +const renderCanvas = (pixels, colors) => { + const img = new ImageData(canvas.width, canvas.height); + const data = new Uint32Array(img.data.buffer); + + board = pixels; + palette = colors; + for (let i = 0; i < pixels.length; i++) { + data[i] = transformHexTo32Bits(colors[pixels[i]]); + } + + canvasCtx.putImageData(img, 0, 0); + canvasCtx.imageSmoothingEnabled = false; + canvas.style.imageRendering = "pixelated"; + + // Remove all the colors from the color wheel + while (colorWheel.firstChild) { + colorWheel.removeChild(colorWheel.firstChild); + } + + // Add the colors to the color wheel + for (let i = 0; i < colors.length; i++) { + const btn = document.createElement("button"); + + colorWheel.appendChild(btn); + + btn.addEventListener("click", () => { + selectedColorIdx = i; + colorPicker.style.color = colors[i]; + colorPicker.style.border = `${colors[i]} 0.1rem solid`; + }); + + btn.style.backgroundColor = colors[i]; + } +}; + +/** + * Initialize the canvas + * @param {*} roomConfig + * @param {number[]} pixels + */ +export const initCanvas = (roomConfig, pixels) => { + const canvasDimensions = roomConfig.metadata.canvasDimensions; + + canvas.width = canvasDimensions; + canvas.height = canvasDimensions; + + positionDisplay({ x: canvasDimensions / 2, y: canvasDimensions / 2 }); + selectedColorIdx = 0; + + const roomColors = roomConfig.settings.roomColors.split(","); + + colorPicker.style.color = roomColors[0]; + colorPicker.style.border = `${roomColors[0]} 0.1rem solid`; + + renderCanvas(pixels, roomColors); +}; + +/** + * Update the canvas + * @param {string} color + * @param {number} x + * @param {number} y + */ +export const renderCanvasUpdate = (color, x, y) => { + const img = new ImageData(canvas.width, canvas.height); + const data = new Uint32Array(img.data.buffer); + + board[y * canvas.width + x] = color; + for (let i = 0; i < board.length; i++) { + data[i] = transformHexTo32Bits(palette[board[i]]); + } + + canvasCtx.putImageData(img, 0, 0); +}; + +/** + * Reset the canvas values + */ +export const resetValues = () => { + zoom = 2.5; + x = 0; + y = 0; + cx = 0; + cy = 0; + isDrag = false; + + positionDisplay({ x, y }); + colorWheelContainer.style.display = "none"; + toggleTooltip(false); +}; + +// Handle scroll on canvas +document.addEventListener("wheel", (e) => { + // Make sure we're scrolling on the canvas or the body and not the UI + if (e.target !== canvas && e.target !== canvasContainer) { + return; + } + + clearInterval(animation); + toggleTooltip(false); + + const delta = Math.sign(e.deltaY) * zoomSpeed; + const zoomFactor = 1 + delta; + const oldZoom = zoom; + const newZoom = Math.max(2.5, Math.min(40, oldZoom * zoomFactor)); + + // Get the position of the mouse relative to the canvas + const mouseX = e.clientX - window.innerWidth / 2; + const mouseY = e.clientY - window.innerHeight / 2; + + // Calculate the new center point based on the mouse position + const newCx = mouseX - (mouseX - cx) * (newZoom / oldZoom); + const newCy = mouseY - (mouseY - cy) * (newZoom / oldZoom); + + if (newZoom !== oldZoom) { + zoom = newZoom; + cx = newCx; + cy = newCy; + positionUpdate(); + } +}); + +// Handle click and drag on canvas +document.addEventListener("mousedown", (e) => { + // Make sure we're clicking on the canvas or the body and not the UI + if (e.target !== canvas && e.target !== canvasContainer) { + return; + } + + e.preventDefault(); + + // Ignore if right click + if (e.button === 2) { + return; + } + + clearInterval(animation); + + isDrag = false; + x = e.clientX; + y = e.clientY; + + document.addEventListener("mousemove", mouseMove); +}); + +// Smooth animation +function easeOutQuart(t, b, c, d) { + t /= d; + t--; + return -c * (t * t * t * t - 1) + b; +} + +// Handle when the user releases the mouse +document.addEventListener("mouseup", (e) => { + document.removeEventListener("mousemove", mouseMove); + + // Make sure we're clicking on the canvas or the body and not the UI + if (e.target !== canvas && e.target !== canvasContainer) { + return; + } + + e.preventDefault(); + + // Get the tile position + target = calculateTarget(e); + + // Make sure we're clicking on the canvas + if ( + target.x >= 0 && + target.x < canvas.width && + target.y >= 0 && + target.y < canvas.height + ) { + // We want to differentiate between a click and a drag + // If it is a click, we want to move the camera to the clicked tile + + // We wait to see if the position changed + // If it did not, we consider it a click + if (!isDrag) { + const duration = 1000; + const startZoom = zoom; + const endZoom = Math.max(15, Math.min(40, zoom)); + + // Get the position of the click in relation to the center of the screen + const clickX = e.clientX - window.innerWidth / 2; + const clickY = e.clientY - window.innerHeight / 2; + const canvaswidthzoom = canvas.width * startZoom; + const canvasheightzoom = canvas.height * startZoom; + const startx = (cx + canvaswidthzoom / 2) / startZoom; + const starty = (cy + canvasheightzoom / 2) / startZoom; + const endx = startx - clickX / startZoom; + const endy = starty - clickY / startZoom; + const endCx = endx * endZoom - (canvas.width / 2) * endZoom; + const endCy = endy * endZoom - (canvas.height / 2) * endZoom; + const startCx = cx; + const startCy = cy; + const startTime = Date.now(); + + // If the distance is small enough, we just warp to it + if ( + Math.abs(endCx - startCx) < 10 && + Math.abs(endCy - startCy) < 10 + ) { + cx = endCx; + cy = endCy; + zoom = endZoom; + canvas.style.transform = `translate(${cx}px, ${cy}px) scale(${zoom})`; + } else { + clearInterval(animation); + + animation = setInterval(() => { + const elapsed = Date.now() - startTime; + + if (elapsed >= duration) { + clearInterval(animation); + return; + } + + const t = elapsed / duration; + + zoom = easeOutQuart(t, startZoom, endZoom - startZoom, 1); + cx = easeOutQuart(t, startCx, endCx - startCx, 1); + cy = easeOutQuart(t, startCy, endCy - startCy, 1); + + positionUpdate(); + }, 10); + } + } + + // Toggle the tooltip if it is a click + toggleTooltip(!isDrag); + + // Update the position of the tooltip + positionDisplay(target); + } +}); + +// Handle mouse move +const mouseMove = (e) => { + e.preventDefault(); + + toggleTooltip(false); + positionUpdate(); + + const dx = e.clientX - x; + const dy = e.clientY - y; + + // For a big enough delta, we consider it a drag + if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) { + isDrag = true; + } + + x = e.clientX; + y = e.clientY; + cx += dx; + cy += dy; + + canvas.style.transform = `translate(${cx}px, ${cy}px) scale(${zoom})`; +}; diff --git a/rushs/eplace/src/rooms/chat/index.js b/rushs/eplace/src/rooms/chat/index.js new file mode 100644 index 0000000..493f142 --- /dev/null +++ b/rushs/eplace/src/rooms/chat/index.js @@ -0,0 +1,4 @@ +// FIXME: This file should handle the room's chat subscription +// Functions may include: +// - subscribeToRoomChat (subscribe to the chat of a room) +// - sendChatMessage (send a chat message) diff --git a/rushs/eplace/src/rooms/chat/utils.js b/rushs/eplace/src/rooms/chat/utils.js new file mode 100644 index 0000000..2d21ab2 --- /dev/null +++ b/rushs/eplace/src/rooms/chat/utils.js @@ -0,0 +1,6 @@ +// FIXME: This file should handle the room's chat DOM manipulation +// Link buttons to their respective functions +// Handle the chat input form and its submission +// Functions may include: +// - displayChatMessage (display a chat message in the DOM) +// - displayUserEvents (display a user event in the DOM) diff --git a/rushs/eplace/src/rooms/index.js b/rushs/eplace/src/rooms/index.js new file mode 100644 index 0000000..dde88e6 --- /dev/null +++ b/rushs/eplace/src/rooms/index.js @@ -0,0 +1,50 @@ +// FIXME: This file should handle the rooms API +// Functions may include: + +import { subscribe } from "../utils/streams"; +import { authedAPIRequest } from "../utils/auth"; + +// - fetchRoomConfig (get the configuration of a room) +export async function fetchRoomConfig(slug) { + const config = { + method: "get", + }; + + return authedAPIRequest(`/rooms/${slug}/config`, config) + .then(async (res) => { + if (!res) { + return null; + } + + const response = await res.json(); + + document.getElementById("room-name").innerHTML = + response.metadata.name; + const description = document.getElementById("room-description"); + + if (response.metadata.description) { + description.innerHTML = response.metadata.description; + description.style.display = "inherit"; + } else { + description.style.display = "none"; + } + + return response; + }) + .catch((error) => { + console.log(error); + return null; + }); +} +// - joinRoom (join a room by its slug) +export function joinRoom(slug) { + if (!slug) { + slug = "epi-place"; + } + + subscribe(slug); +} +// - listRooms (list all the rooms available) +// - createRoom (create a room) +// - updateRoom (update a room's configuration) +// - deleteRoom (delete a room) diff --git a/rushs/eplace/src/rooms/utils.js b/rushs/eplace/src/rooms/utils.js new file mode 100644 index 0000000..5e94739 --- /dev/null +++ b/rushs/eplace/src/rooms/utils.js @@ -0,0 +1,6 @@ +// FIXME: This file should handle the rooms DOM manipulation +// Link buttons to their respective functions +// Functions may include: +// - showModal (add a form modal to the DOM) +// - createRoomObject (create a room in the DOM) +// - displayRoomsList (display the rooms list in the DOM) diff --git a/rushs/eplace/src/students/index.js b/rushs/eplace/src/students/index.js new file mode 100644 index 0000000..2aa18a2 --- /dev/null +++ b/rushs/eplace/src/students/index.js @@ -0,0 +1,19 @@ +// FIXME: This file should handle the students API +// Functions may include: + +import { authedAPIRequest } from "../utils/auth"; + +// - getStudent (get a student from the API by its uid or login) +export async function getStudent(id) { + const response = await authedAPIRequest(`/students/${id}`, { + method: "get", + }); + + if (!response) { + return null; + } else { + return response.json(); + } +} +// - getUserUidFromToken (get the user's uid from the token in local storage) +// - updateStudent (update the student's profile through the API) diff --git a/rushs/eplace/src/students/utils.js b/rushs/eplace/src/students/utils.js new file mode 100644 index 0000000..09bb32e --- /dev/null +++ b/rushs/eplace/src/students/utils.js @@ -0,0 +1,5 @@ +// FIXME: This file should handle the students DOM manipulation +// Link buttons to their respective functions +// Functions may include: +// - displayStudentProfile (display the student's profile in the DOM) +// - showModal (add a form modal to the DOM) diff --git a/rushs/eplace/src/utils/auth.js b/rushs/eplace/src/utils/auth.js new file mode 100644 index 0000000..2576282 --- /dev/null +++ b/rushs/eplace/src/utils/auth.js @@ -0,0 +1,124 @@ +// FIXME: This file should handle the authentication +// Exports must include: +// - authedAPIRequest (make an authenticated request to the API) + +/** + * This function makes an authenticated request to the API + * This function must always hit the /api/* endpoint. + * @param {string} endpoint + * @param {object} options This object should at least contain the method. + * For the other options, you can refer to the fetch documentation as it should be the same. + * @returns {Promise<Response>} the response + * We want a {Promise<Response>} so we can read the headers as well as the body, rather than + * just the body. + **/ +export async function authedAPIRequest(endpoint, options) { + if (localStorage.getItem("token")) { + // essaye de contacter l'endpoint + const headers = { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }; + + if (options["headers"]) { + options["headers"]["Authorization"] = + `Bearer ${localStorage.getItem("token")}`; + } else { + options["headers"] = headers; + } + + const apiURL = `${import.meta.env.VITE_URL}/api${endpoint}`; + + try { + const response = await fetch(apiURL, options); + + if (response.status === 200) { + return response; + } else { + console.log("request error"); + console.log(response); + if ( + response.status === 401 && + (await response.json()).message.match(/Token expired/) + ) { + localStorage.removeItem("token"); + if (!(await sendTokenRequest())) { + return null; + } + + const response = await authedAPIRequest(endpoint, options); + + if (!response) { + return null; + } + + return response; + } else { + localStorage.clear(); + // window.location.replace(import.meta.env.VITE_URL); + await sendTokenRequest(); + return null; + } + } + } catch { + console.log("an error occured while fetching"); + return null; + } + } + + await sendTokenRequest(); + return null; +} + +// Functions may include: +// - sendTokenRequest (get a token or refresh it) +export async function sendTokenRequest() { + if ( + !localStorage.getItem("token") && + !localStorage.getItem("refresh_token") + ) { + const authQueryParams = { + client_id: import.meta.env.VITE_CLIENT_ID, + scope: "epita profile picture", + redirect_uri: `${import.meta.env.VITE_URL}/complete/epita/`, + response_type: "code", + }; + const url = new URL(`${import.meta.env.VITE_AUTH_URL}/authorize`); + + //`?client_id=${authQueryParams.client_id}&scope=${authQueryParams.scope}&redirect_uri=${authQueryParams.redirect_uri}&response_type=${authQueryParams.response_type}`, + url.searchParams.append("client_id", authQueryParams.client_id); + url.searchParams.append("scope", authQueryParams.scope); + url.searchParams.append("redirect_uri", authQueryParams.redirect_uri); + url.searchParams.append("response_type", authQueryParams.response_type); + + window.location.replace(url); + return false; + } else if (localStorage.getItem("refresh_token")) { + const form = new FormData(); + + form.append("client_id", import.meta.env.VITE_CLIENT_ID); + form.append( + "redirect_uri", + `${import.meta.env.VITE_URL}/complete/epita/`, + ); + form.append("grant_type", "refresh_token"); + form.append("refresh_token", localStorage.getItem("refresh_token")); + const res = await fetch(`${import.meta.env.VITE_URL}/auth-api/token`, { + method: "POST", + body: form, + }); + + if (res.status === 200) { + const response = await res.json(); + + localStorage.setItem("token", response.id_token); + localStorage.setItem("refresh_token", response.refresh_token); + return true; + } else { + localStorage.clear(); + // window.location.replace(import.meta.env.VITE_URL); + // HERE ptetre return direct senTokenRequest + await sendTokenRequest(); + return false; + } + } +} diff --git a/rushs/eplace/src/utils/notify.js b/rushs/eplace/src/utils/notify.js new file mode 100644 index 0000000..b6ed7dc --- /dev/null +++ b/rushs/eplace/src/utils/notify.js @@ -0,0 +1,57 @@ +import $ from "jquery"; +import alertHtml from "../components/notifications/index.html"; + +const alertContainer = $("#alert-container")?.[0]; + +const iconMap = { + info: "fa-info-circle", + success: "fa-thumbs-up", + warning: "fa-exclamation-triangle", + error: "ffa fa-exclamation-circle", +}; + +/** + * Create an alert + * @param {string} title + * @param {string} message + * @param {string} type - success, warning, error + */ +export const createAlert = (title, message, type) => { + $.ajax({ + url: alertHtml, + success: (data) => { + const [alert] = $(data); + + // Return if the alert cannot be created, usefull when a redirect is made + if (!alertContainer || !alert || !alert.classList) { + return; + } + + // Add the type class to the alert + alert.classList.add( + `Alert${type.charAt(0).toUpperCase() + type.slice(1)}`, + ); + + // Replace values in innerHTML + alert.innerHTML = alert.innerHTML + .replace(/{{title}}/g, title) + .replace(/{{content}}/g, message) + .replace(/{{icon_classes}}/g, iconMap[type]); + + // Get the close button + const closeBtn = alert.getElementsByClassName("AlertClose")?.[0]; + + closeBtn?.addEventListener("click", () => { + alert.remove(); + }); + + // Append the alert to the container + alertContainer.append(alert); + + // Remove the alert after 5 seconds + setTimeout(() => { + alert.remove(); + }, 5000); + }, + }); +}; diff --git a/rushs/eplace/src/utils/rateLimits.js b/rushs/eplace/src/utils/rateLimits.js new file mode 100644 index 0000000..d7b11a6 --- /dev/null +++ b/rushs/eplace/src/utils/rateLimits.js @@ -0,0 +1,3 @@ +// FIXME: This file should handle the rate limits +// Functions may include: +// - displayTimer (util function to display the timer for the rate limit) diff --git a/rushs/eplace/src/utils/streams.js b/rushs/eplace/src/utils/streams.js new file mode 100644 index 0000000..f714527 --- /dev/null +++ b/rushs/eplace/src/utils/streams.js @@ -0,0 +1,108 @@ +// FIXME: This file should handle the sockets and the subscriptions +import { io } from "socket.io-client"; +import { v4 as uuidv4 } from "uuid"; + +import { createAlert } from "./notify"; +import { fetchRoomConfig, joinRoom } from "../rooms"; +import { getCanvas } from "../rooms/canvas"; +import { initCanvas, renderCanvasUpdate } from "../rooms/canvas/utils"; +import { sendTokenRequest } from "../utils/auth"; + +let subscribed = false; + +export let socket = undefined; + +const updateBuffer = []; + +// Exports must include +// - initSocket (initialize the connection to the socket server) +export async function initSocket() { + if (!localStorage.getItem("token")) { + if (!(await sendTokenRequest())) { + createAlert("ForgeID", "Authentication failed", "error"); + return; + } + + createAlert("ForgeID", "Authentication successful", "success"); + } + + if (socket) { + return socket; + } + + socket = io(import.meta.env.VITE_URL, { + extraHeaders: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + socket.on("connect", () => { + joinRoom(); + }); + + socket.on("message", async (msg) => { + if (msg.result && msg.result.type === "started") { + const roomConfig = await fetchRoomConfig("epi-place"); + + if (!roomConfig) { + return; + } + + const pixels = await getCanvas("epi-place"); + + if (!pixels) { + return; + } + + initCanvas(roomConfig, pixels); + // debuffer pixel updates + catchUpOnUpdates(); + subscribed = true; + } + }); + + socket.on("pixel-update", (update) => { + if (!subscribed) { + updateBuffer.push({ + color: update.result.data.json.color, + posX: update.result.data.json.posX, + posY: update.result.data.json.posY, + }); + } else { + renderCanvasUpdate( + update.result.data.json.color, + update.result.data.json.posX, + update.result.data.json.posY, + ); + } + }); + return socket; +} +// - socket (variable resulting of initSocket function) + +// Functions may include: +// - subscribe (subscribe to a room's stream or chat) +export function subscribe(slug) { + const subscription = { + id: uuidv4(), + method: "subscription", + params: { + path: "rooms.canvas.getStream", + input: { + json: { + roomSlug: slug, + }, + }, + }, + }; + + socket.send(subscription); +} +function catchUpOnUpdates() { + while (updateBuffer.length > 0) { + const update = updateBuffer.pop(); + + renderCanvasUpdate(update.color, update.posX, update.posY); + } +} +// - unsubscribe (unsubscribe from a room's stream or chat) +// - sendMessage (send a message to a room's chat) |
