├── images ├── no-js.png ├── favicon.ico └── default-avatar.jpg ├── scripts ├── config.js ├── utils │ ├── error.js │ ├── date.js │ └── svg.js ├── main.js ├── api │ ├── authRequests.js │ ├── graphqlRequests.js │ └── graphql.js ├── app │ ├── handleProfile.js │ └── handleAuth.js └── components │ ├── authComponent.js │ ├── profile │ ├── renderLevel.js │ ├── renderTransactions.js │ └── renderAudits.js │ ├── profileComponent.js │ ├── graphs │ ├── skillsChart.js │ └── transactionsChart.js │ └── talents │ ├── talentsInfo.css │ └── talentInfo.js ├── styles ├── login.css ├── app.css └── profile.css ├── index.html └── README.md /images/no-js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/graphql/HEAD/images/no-js.png -------------------------------------------------------------------------------- /images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/graphql/HEAD/images/favicon.ico -------------------------------------------------------------------------------- /images/default-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmaach/graphql/HEAD/images/default-avatar.jpg -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | export const AUTH_URL = 'https://learn.zone01oujda.ma/api/auth/signin' 2 | 3 | export const GRAPHQL_URL = 'https://learn.zone01oujda.ma/api/graphql-engine/v1/graphql' -------------------------------------------------------------------------------- /scripts/utils/error.js: -------------------------------------------------------------------------------- 1 | export const writeErrorMessage = (elementID, message) => { 2 | const errorElement = document.getElementById(elementID); 3 | if (errorElement) { 4 | errorElement.textContent = message 5 | } 6 | } -------------------------------------------------------------------------------- /scripts/utils/date.js: -------------------------------------------------------------------------------- 1 | export const formatDate = (date) => { 2 | if (!date) return "" 3 | date = new Date(date) 4 | const day = date.getDate().toString().padStart(2, '0'); 5 | const month = (date.getMonth() + 1).toString().padStart(2, '0'); 6 | const year = date.getFullYear(); 7 | return `${day}-${month}-${year}`; 8 | } -------------------------------------------------------------------------------- /scripts/main.js: -------------------------------------------------------------------------------- 1 | import { handleLogin } from "./app/handleAuth.js" 2 | import { handleProfile } from "./app/handleProfile.js" 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | const jwt = localStorage.getItem('JWT') 6 | if (jwt) { 7 | handleProfile() 8 | } else { 9 | handleLogin() 10 | } 11 | }) -------------------------------------------------------------------------------- /scripts/api/authRequests.js: -------------------------------------------------------------------------------- 1 | import { AUTH_URL } from "../config.js" 2 | 3 | export const submitLogin = async (credentials) => { 4 | const response = await fetch(AUTH_URL, { 5 | method: 'POST', 6 | headers: { 7 | Authorization: `Basic ${btoa(credentials.username + ":" + credentials.password)}` 8 | } 9 | }); 10 | return response.json(); 11 | } -------------------------------------------------------------------------------- /scripts/api/graphqlRequests.js: -------------------------------------------------------------------------------- 1 | import { GRAPHQL_URL } from "../config.js"; 2 | 3 | export const fetchGraphQL = async (query, variables, token) => { 4 | const response = await fetch(GRAPHQL_URL, { 5 | method: 'POST', 6 | headers: { 7 | 'Content-Type': 'application/json', 8 | 'Authorization': `Bearer ${token}`, 9 | }, 10 | body: JSON.stringify({ 11 | query: query, 12 | variables: variables, 13 | }), 14 | }); 15 | 16 | return response.json(); 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /styles/login.css: -------------------------------------------------------------------------------- 1 | .login-container { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | width: 100%; 6 | min-height: 100vh; 7 | padding: var(--container-padding); 8 | } 9 | 10 | .login-card { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | gap: 20px; 16 | width: min(500px, 90%); 17 | padding: clamp(15px, 3vw, 20px); 18 | border-radius: 10px; 19 | background-color: var(--bg-color-light); 20 | border: solid 2px var(--border-color); 21 | } 22 | 23 | .login-card input { 24 | width: 100%; 25 | max-width: 350px; 26 | padding: 10px; 27 | border-radius: 5px; 28 | border: 2px transparent solid; 29 | outline: none; 30 | font-size: clamp(0.875rem, 2vw, 1rem); 31 | font-weight: 600; 32 | color: var(--text-color-dark); 33 | } 34 | 35 | .login-card input:focus { 36 | border: var(--primary-color) solid 2px; 37 | outline: none; 38 | } -------------------------------------------------------------------------------- /scripts/app/handleProfile.js: -------------------------------------------------------------------------------- 1 | import { fetchGraphQL } from "../api/graphqlRequests.js" 2 | import { GET_USER_INFO } from "../api/graphql.js" 3 | import { renderProfilePage } from "../components/profileComponent.js" 4 | import { handleLogout } from "./handleAuth.js" 5 | 6 | export const handleProfile = async () => { 7 | const token = localStorage.getItem('JWT') 8 | 9 | fetchGraphQL(GET_USER_INFO, {}, token) 10 | .then((response) => { 11 | if (Array.isArray(response.errors)) { 12 | throw response.errors[0].message 13 | } 14 | const user = response?.data.user; 15 | if (response && Array.isArray(user)) { 16 | renderProfilePage(user[0]) 17 | } else { 18 | throw new Error("Invalid data received!"); 19 | } 20 | }) 21 | .catch((error) => { 22 | if (typeof error === "string" && error.includes('JWTExpired')) handleLogout() 23 | console.error(error); 24 | }); 25 | } -------------------------------------------------------------------------------- /scripts/components/authComponent.js: -------------------------------------------------------------------------------- 1 | import { writeErrorMessage } from "../utils/error.js"; 2 | 3 | export const renderLoginPage = () => { 4 | const container = document.createElement('div'); 5 | container.innerHTML = /*html*/` 6 |
7 |
8 |

Login

9 | 10 | 11 | 12 | 13 |
14 |
` 15 | 16 | document.body.appendChild(container); 17 | 18 | // empty the error message 19 | document.getElementById('username')?.addEventListener("focus", () => { 20 | writeErrorMessage("login-error", "") 21 | }) 22 | document.getElementById('password')?.addEventListener("focus", () => { 23 | writeErrorMessage("login-error", "") 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /scripts/app/handleAuth.js: -------------------------------------------------------------------------------- 1 | import { submitLogin } from "../api/authRequests.js" 2 | import { renderLoginPage } from "../components/authComponent.js" 3 | import { writeErrorMessage } from "../utils/error.js" 4 | import { handleProfile } from "./handleProfile.js" 5 | 6 | export const handleLogin = () => { 7 | renderLoginPage() 8 | const form = document.getElementById("login-form") 9 | form.addEventListener('submit', async (e) => { 10 | e.preventDefault() 11 | const credentials = { 12 | username: form?.username.value, 13 | password: form?.password.value, 14 | } 15 | try { 16 | const response = await submitLogin(credentials) 17 | if (response.error) { 18 | throw response.error 19 | } 20 | localStorage.setItem('JWT', response) 21 | handleProfile() 22 | } catch (error) { 23 | writeErrorMessage("login-error", error) 24 | } 25 | }) 26 | } 27 | 28 | export const handleLogout = () => { 29 | localStorage.removeItem('JWT') 30 | document.body.innerHTML = `` 31 | handleLogin() 32 | } 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | GraphQL 15 | 16 | 17 | 18 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /scripts/components/profile/renderLevel.js: -------------------------------------------------------------------------------- 1 | import { fetchGraphQL } from "../../api/graphqlRequests.js"; 2 | import { GET_LEVEL_INFO } from "../../api/graphql.js"; 3 | 4 | export const renderLevelComponenet = async () => { 5 | // Fetch audits info 6 | const token = localStorage.getItem("JWT"); 7 | let data 8 | 9 | await fetchGraphQL(GET_LEVEL_INFO, {}, token) 10 | .then((response) => { 11 | if (Array.isArray(response.errors)) { 12 | throw response.errors[0].message; 13 | } 14 | 15 | if (response && Array.isArray(response.data.transaction)) { 16 | data = response.data.transaction[0].amount 17 | } else { 18 | throw new Error("Invalid data received!"); 19 | } 20 | }) 21 | .catch((error) => { 22 | if (typeof error === "string" && error.includes('JWTExpired')) handleLogout(); 23 | console.error(error); 24 | }); 25 | 26 | 27 | // Render audit info 28 | const container = document.getElementById("level-info"); 29 | 30 | container.innerHTML = /*html*/ ` 31 |
32 |

Your current Level

33 |
34 | ${data} 35 |
36 | 37 | `; 38 | } -------------------------------------------------------------------------------- /scripts/components/profile/renderTransactions.js: -------------------------------------------------------------------------------- 1 | import { fetchGraphQL } from "../../api/graphqlRequests.js"; 2 | import { GET_LAST_TRANSACTIONS } from "../../api/graphql.js"; 3 | 4 | export const renderLastTransComponent = async () => { 5 | // Fetch audits info 6 | const token = localStorage.getItem("JWT"); 7 | let data 8 | 9 | await fetchGraphQL(GET_LAST_TRANSACTIONS, {}, token) 10 | .then((response) => { 11 | if (Array.isArray(response.errors)) { 12 | throw response.errors[0].message; 13 | } 14 | 15 | if (response && Array.isArray(response.data.user[0].transactions)) { 16 | data = response.data.user[0].transactions 17 | } else { 18 | throw new Error("Invalid data received!"); 19 | } 20 | }) 21 | .catch((error) => { 22 | if (typeof error === "string" && error.includes('JWTExpired')) handleLogout(); 23 | console.error(error); 24 | }); 25 | 26 | 27 | // Render audit info 28 | const container = document.getElementById("last-transactions-info"); 29 | 30 | container.innerHTML = /*html*/ ` 31 |
32 |

Last three transactions

33 |
34 | ${data.map(transaction => /*html*/` 35 |
36 | ${transaction.object.name} 37 | ${transaction.amount/1000} KB 38 | ${new Date(transaction.createdAt).toLocaleDateString()} 39 |
40 | `).join('')} 41 |
42 | `; 43 | } -------------------------------------------------------------------------------- /scripts/components/profileComponent.js: -------------------------------------------------------------------------------- 1 | import { handleLogout } from "../app/handleAuth.js"; 2 | import { renderAuditsInfo } from "./profile/renderAudits.js"; 3 | import { renderSkillsChart } from "./graphs/skillsChart.js"; 4 | import { renderTransactionsChart } from "./graphs/transactionsChart.js"; 5 | import { renderLastTransComponent } from "./profile/renderTransactions.js"; 6 | import { renderLevelComponenet } from "./profile/renderLevel.js"; 7 | import { renderTalentInfo } from "./talents/talentInfo.js"; 8 | 9 | export const renderProfilePage = (user) => { 10 | document.body.innerHTML = ``; 11 | 12 | // Create container 13 | const container = document.createElement('div'); 14 | container.className = "main-container"; 15 | container.innerHTML = /*html*/ ` 16 |
17 |
18 |
19 |

Welcome back, ${user.firstName} ${user.lastName}!

20 |

Here’s your dashboard overview.

21 |
22 | 25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
`; 38 | 39 | document.body.appendChild(container); 40 | document.getElementById('logout-button')?.addEventListener('click', handleLogout); 41 | 42 | renderAuditsInfo() 43 | renderLevelComponenet() 44 | renderLastTransComponent() 45 | renderTalentInfo() 46 | renderSkillsChart() 47 | renderTransactionsChart() 48 | }; 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL 2 | 3 | ## Authors 4 | - Hamza Maach 5 | 6 | ## Project Description 7 | This project involves creating a personalized profile page using GraphQL. The goal is to query user-specific data from a GraphQL endpoint and present it in a visually appealing and interactive interface. The profile page will include sections for basic user information, achievements, and statistics. Additionally, the page will feature two SVG-based graphs to display data insights. A secure login system with JWT-based authentication is implemented to access user data. 8 | 9 | ## Features 10 | ### User Authentication 11 | - Login functionality with email/username and password 12 | - JWT-based authentication to access the GraphQL API 13 | - Error handling for invalid credentials 14 | - Logout functionality 15 | 16 | ### Profile Page 17 | - Displays personalized user data retrieved via GraphQL queries 18 | - Includes the following sections: 19 | - Basic user information 20 | - Audit Statistics 21 | - Level Achievement 22 | - Graphs showcase various metrics such as: 23 | - Skills overview 24 | - XP earned over time 25 | 26 | ### Hosting 27 | - The profile page is hosted online for easy access using Netlify, you can find it here : 28 | 29 | ## Project Structure 30 | ``` 31 | graphql/ 32 | ├── images/ 33 | ├── scripts/ 34 | │ ├── api/ # GraphQL query implementations 35 | │ ├── app/ # Core application logic 36 | │ ├── components/ # UI components 37 | │ ├── graphs/ # Graph generation components 38 | │ └── profile/ # Profile-specific components 39 | │ ├── authComponent.js # Login/logout functionality 40 | │ └── profileComponent.js # Profile data handling 41 | ├── styles/ # CSS styles 42 | ├── index.html # Single page application entry point 43 | └── README.md # Project documentation 44 | ``` 45 | 46 | ## Technologies 47 | ### Frontend 48 | - HTML5 & CSS3 49 | - Font Awesome icons 50 | - Vanilla JavaScript (No frameworks) 51 | - Single Page Application architecture 52 | - GraphQL queries for data retrieval 53 | - SVG for interactive and animated graphs 54 | -------------------------------------------------------------------------------- /styles/app.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Alegreya+Sans:ital,wght@0,100;0,300;0,400;0,500;0,700;0,800;0,900;1,100;1,300;1,400;1,500;1,700;1,800;1,900&display=swap'); 2 | 3 | :root { 4 | --bg-color: rgb(26, 25, 28); 5 | --bg-color-light: rgb(39, 37, 41); 6 | --primary-color: rgb(170, 130, 0); 7 | --text-color: white; 8 | --text-color-secondary: rgb(187, 187, 187); 9 | --text-color-dark: rgb(26, 25, 28); 10 | --border-color: rgb(255, 255, 255); 11 | --transition: ease 0.2s 12 | } 13 | 14 | * { 15 | padding: 0; 16 | margin: 0; 17 | } 18 | 19 | body { 20 | font-family: "Alegreya Sans", serif; 21 | background-color: var(--bg-color); 22 | color: var(--text-color); 23 | transition: var( --transition); 24 | } 25 | 26 | .btn { 27 | background-color: var(--primary-color); 28 | outline: none; 29 | border: none; 30 | color: var(--text-color); 31 | padding: 10px 20px; 32 | font-size: large; 33 | font-weight: 600; 34 | border-radius: 10px; 35 | cursor: pointer; 36 | transition: var(--transition); 37 | } 38 | 39 | .btn:hover { 40 | box-shadow: var(--primary-color) 1px 1px 7px; 41 | } 42 | 43 | .error { 44 | display: inline-block; 45 | min-height: 25px; 46 | color: red; 47 | font-weight: bold; 48 | margin-top: 10px; 49 | } 50 | 51 | #no-script { 52 | display: flex; 53 | justify-content: center; 54 | align-items: center; 55 | width: 100%; 56 | height: 100vh; 57 | } 58 | 59 | #no-script>div { 60 | display: flex; 61 | justify-content: center; 62 | align-items: center; 63 | flex-direction: column; 64 | gap: 20px; 65 | color: black; 66 | background-color: white; 67 | border-radius: 20px; 68 | width: 60%; 69 | padding: 30px; 70 | } 71 | 72 | #no-script>div img { 73 | width: 200px; 74 | height: 200px; 75 | border-radius: 0%; 76 | } 77 | 78 | #no-script>div h1 { 79 | font-size: 25px; 80 | font-weight: 700; 81 | } 82 | 83 | #no-script>div p { 84 | font-size: 15px; 85 | display: inline-block; 86 | width: 100%; 87 | text-align: center; 88 | 89 | } -------------------------------------------------------------------------------- /scripts/components/profile/renderAudits.js: -------------------------------------------------------------------------------- 1 | import { fetchGraphQL } from "../../api/graphqlRequests.js"; 2 | import { GET_AUDITS_INFO } from "../../api/graphql.js"; 3 | 4 | export const renderAuditsInfo = async () => { 5 | // Fetch audits info 6 | const token = localStorage.getItem("JWT"); 7 | let data 8 | 9 | await fetchGraphQL(GET_AUDITS_INFO, {}, token) 10 | .then((response) => { 11 | if (Array.isArray(response.errors)) { 12 | throw response.errors[0].message; 13 | } 14 | 15 | data = response?.data.user[0]; 16 | if (!response && typeof data !== 'object') { 17 | throw new Error("Invalid data received!"); 18 | } 19 | }) 20 | .catch((error) => { 21 | if (typeof error === "string" && error.includes('JWTExpired')) handleLogout(); 22 | console.error(error); 23 | }); 24 | 25 | 26 | const succeeded = data.audits_aggregate.aggregate.count 27 | const failed = data.failed_audits.aggregate.count 28 | const total = succeeded + failed 29 | 30 | const succeededPercentage = (succeeded / total) * 100 31 | const failedPercentage = (failed / total) * 100 32 | 33 | // Render audit info 34 | const container = document.getElementById("audits-info"); 35 | 36 | container.innerHTML = /*html*/ ` 37 |
38 |

Your Audit Statistics

39 |
40 |
41 | ${(data.auditRatio).toFixed(1)} 42 | Audit Ratio 43 |
44 |
45 | ${total} 46 | Total Audits 47 |
48 |
49 | ${(succeededPercentage).toFixed(1)} % 50 | Success Rate 51 |
52 |
53 | ${(failedPercentage).toFixed(1)} % 54 | Fail Rate 55 |
56 |
57 | `; 58 | } -------------------------------------------------------------------------------- /scripts/utils/svg.js: -------------------------------------------------------------------------------- 1 | export const createSvgElement = (name, attributes) => { 2 | const svgNS = "http://www.w3.org/2000/svg"; 3 | const element = document.createElementNS(svgNS, name); 4 | Object.entries(attributes).forEach(([key, value]) => { 5 | element.setAttribute(key, value); 6 | }); 7 | return element; 8 | } 9 | 10 | export const drawAxes = (svg, width, height, padding, axisColor) => { 11 | const xAxis = createSvgElement("line", { 12 | x1: padding, 13 | y1: height - padding, 14 | x2: width - padding, 15 | y2: height - padding, 16 | stroke: axisColor, 17 | "stroke-width": "1", 18 | }); 19 | const yAxis = createSvgElement("line", { 20 | x1: padding, 21 | y1: padding, 22 | x2: padding, 23 | y2: height - padding, 24 | stroke: axisColor, 25 | "stroke-width": "1", 26 | }); 27 | svg.appendChild(xAxis); 28 | svg.appendChild(yAxis); 29 | }; 30 | 31 | export const drawGridlines = (svg, width, height, padding, axisColor, maxAmount, minAmount, numYLines, unit) => { 32 | const chartHeight = height - 2 * padding; 33 | const step = chartHeight / numYLines; 34 | 35 | for (let i = 0; i <= numYLines; i++) { 36 | const y = padding + i * step; 37 | const amount = maxAmount - ((maxAmount - minAmount) * i) / numYLines; 38 | 39 | svg.appendChild( 40 | createSvgElement("line", { 41 | x1: padding, 42 | y1: y, 43 | x2: width - padding, 44 | y2: y, 45 | stroke: axisColor, 46 | "stroke-width": "1", 47 | "stroke-dasharray": "1,5", 48 | "stroke-opacity": "0.4", 49 | }) 50 | ); 51 | 52 | const label = createSvgElement("text", { 53 | x: padding - 10, 54 | y: y, 55 | "text-anchor": "end", 56 | fill: axisColor, 57 | "font-size": "10", 58 | }); 59 | 60 | label.textContent = `${Math.round(amount > 100 ? amount / 1000 : amount)} ${unit}`; 61 | svg.appendChild(label); 62 | } 63 | }; 64 | 65 | 66 | export const getMaxAmountPerSkill = (transactions) => { 67 | const maxSkillMap = new Map(); 68 | 69 | transactions.forEach((transaction) => { 70 | const { type, amount } = transaction; 71 | 72 | if (!maxSkillMap.has(type) || maxSkillMap.get(type) < amount) { 73 | maxSkillMap.set(type, amount); 74 | } 75 | }); 76 | 77 | return maxSkillMap; 78 | } -------------------------------------------------------------------------------- /scripts/api/graphql.js: -------------------------------------------------------------------------------- 1 | export const GET_USER_INFO = /*gql*/` 2 | { 3 | user { 4 | firstName 5 | lastName 6 | } 7 | }` 8 | 9 | export const GET_AUDITS_INFO = /*gql*/` 10 | { 11 | user { 12 | auditRatio 13 | audits_aggregate(where: {closureType: {_eq: succeeded}}) { 14 | aggregate { 15 | count 16 | } 17 | } 18 | failed_audits: audits_aggregate(where: {closureType: {_eq: failed}}) { 19 | aggregate { 20 | count 21 | } 22 | } 23 | } 24 | }` 25 | 26 | 27 | export const GET_LEVEL_INFO = /*gql*/` 28 | { 29 | transaction( 30 | where: {_and: [{type: {_eq: "level"}}, {event: {object: {name: {_eq: "Module"}}}}]} 31 | order_by: {amount: desc} 32 | limit: 1 33 | ) { 34 | amount 35 | } 36 | }` 37 | 38 | export const GET_LAST_TRANSACTIONS = /*gql*/` 39 | { 40 | user { 41 | transactions(limit: 3, where: {type: {_eq: "xp"}}, order_by: {createdAt: desc}) { 42 | object { 43 | name 44 | } 45 | amount 46 | createdAt 47 | } 48 | } 49 | }` 50 | 51 | export const GET_SKILLS = /*gql*/` 52 | { 53 | user { 54 | transactions(where: {type: {_nin: ["xp", "level", "up", "down"]}}) { 55 | type 56 | amount 57 | } 58 | } 59 | }` 60 | 61 | export const GET_TRANSACTIONS = /*gql*/` 62 | query GetTransactions($name: String!) { 63 | event(where: {object: {name: {_eq: $name}}}){ 64 | object{ 65 | events{ 66 | startAt 67 | endAt 68 | } 69 | } 70 | } 71 | transaction( 72 | where: { 73 | _and: [ 74 | { type: { _eq: "xp" } }, 75 | { event: { object: { name: { _eq: $name } } } }, 76 | ] 77 | }, 78 | order_by: {createdAt: asc} 79 | ) { 80 | amount 81 | object { 82 | name 83 | } 84 | createdAt 85 | } 86 | }` 87 | 88 | 89 | export const GET_RANDOM_USERS = /*gql*/` 90 | { 91 | group{ 92 | object { 93 | name 94 | } 95 | members { 96 | user { 97 | firstName 98 | lastName 99 | login 100 | avatarUrl 101 | } 102 | } 103 | } 104 | } 105 | ` 106 | 107 | // export const GET_RANDOM_USERS = /*gql*/` 108 | // { 109 | // group( 110 | // where: {event: {object: {attrs: {_contains: {graph: {innerCircle: [{innerArc: {contents: [{}]}}]}}}}}} 111 | // ) { 112 | // object { 113 | // name 114 | // } 115 | // members { 116 | // user { 117 | // firstName 118 | // lastName 119 | // login 120 | // avatarUrl 121 | // } 122 | // } 123 | // } 124 | // } 125 | // ` -------------------------------------------------------------------------------- /scripts/components/graphs/skillsChart.js: -------------------------------------------------------------------------------- 1 | import { fetchGraphQL } from "../../api/graphqlRequests.js"; 2 | import { GET_SKILLS } from "../../api/graphql.js"; 3 | import { createSvgElement, drawAxes, drawGridlines, getMaxAmountPerSkill } from "../../utils/svg.js"; 4 | 5 | export const renderSkillsChart = async () => { 6 | const token = localStorage.getItem("JWT"); 7 | let skillsMap = []; 8 | 9 | await fetchGraphQL(GET_SKILLS, {}, token) 10 | .then((response) => { 11 | if (Array.isArray(response.errors)) { 12 | throw response.errors[0].message; 13 | } 14 | const transactions = response?.data.user[0].transactions; 15 | if (response && Array.isArray(transactions)) { 16 | skillsMap = Array.from(getMaxAmountPerSkill(transactions).entries()); 17 | } else { 18 | throw new Error("Invalid data received!"); 19 | } 20 | }) 21 | .catch((error) => { 22 | if (typeof error === "string" && error.includes('JWTExpired')) handleLogout(); 23 | console.error(error); 24 | }); 25 | 26 | const container = document.getElementById("skills-chart"); 27 | container.innerHTML = /*html*/ ` 28 |
29 |
30 |

Your skills

31 |
32 | `; 33 | 34 | const width = Math.min(900, container.clientWidth); 35 | const height = width * 0.6; 36 | const padding = 50; 37 | const barWidth = (width - 2 * padding) / skillsMap.length; 38 | 39 | const axisColor = getComputedStyle(document.documentElement).getPropertyValue("--text-color"); 40 | const barColor = getComputedStyle(document.documentElement).getPropertyValue("--primary-color"); 41 | 42 | 43 | const svg = createSvgElement("svg", { 44 | width, 45 | height, 46 | viewBox: `0 0 ${width} ${height}`, 47 | }); 48 | 49 | const maxAmount = 100; 50 | const minAmount = 0; 51 | 52 | // Draw axes and gridlines 53 | drawAxes(svg, width, height, padding, axisColor); 54 | drawGridlines(svg, width, height, padding, axisColor, maxAmount, minAmount, 10, "%"); 55 | 56 | // Draw bars for each skill 57 | skillsMap.forEach(([skill, amount], index) => { 58 | const barHeight = (amount / maxAmount) * (height - 2 * padding); 59 | const x = padding + index * barWidth; 60 | const y = height - padding - barHeight; 61 | 62 | // Create bar 63 | const bar = createSvgElement("rect", { 64 | x, 65 | y, 66 | width: barWidth * 0.8, // Add spacing between bars 67 | height: barHeight, 68 | fill: barColor, 69 | }); 70 | svg.appendChild(bar); 71 | 72 | // Add skill labels 73 | const label = createSvgElement("text", { 74 | x: x + barWidth * 0.4, 75 | y: height - padding + 15, 76 | "text-anchor": "middle", 77 | "font-size": "10", 78 | fill: axisColor, 79 | }); 80 | label.textContent = skill.replace("skill_", ""); 81 | svg.appendChild(label); 82 | 83 | // Add value labels 84 | const valueLabel = createSvgElement("text", { 85 | x: x + barWidth * 0.4, 86 | y: y - 5, 87 | "text-anchor": "middle", 88 | "font-size": "13", 89 | fill: axisColor, 90 | }); 91 | valueLabel.textContent = amount +" %"; 92 | svg.appendChild(valueLabel); 93 | }); 94 | 95 | container.appendChild(svg); 96 | }; 97 | -------------------------------------------------------------------------------- /scripts/components/graphs/transactionsChart.js: -------------------------------------------------------------------------------- 1 | import { fetchGraphQL } from "../../api/graphqlRequests.js"; 2 | import { GET_TRANSACTIONS } from "../../api/graphql.js"; 3 | import { formatDate } from "../../utils/date.js"; 4 | import { createSvgElement, drawAxes, drawGridlines } from "../../utils/svg.js"; 5 | 6 | export const renderTransactionsChart = async () => { 7 | const token = localStorage.getItem("JWT"); 8 | const name = "Module"; 9 | 10 | const data = await getTransactionsData(name, token); 11 | if (!data) return; 12 | 13 | const { startAt, endAt, transactions } = data; 14 | 15 | const container = document.getElementById("transactions-chart"); 16 | container.innerHTML = /*html*/` 17 |
18 |
19 |

${name}

20 | ${formatDate(startAt)} -> ${formatDate(endAt)} 21 |
22 | `; 23 | 24 | const width = Math.min(900, container.clientWidth); 25 | const height = width * 0.5; 26 | const padding = 50; 27 | 28 | const axisColor = getComputedStyle(document.documentElement).getPropertyValue("--text-color"); 29 | const lineColor = getComputedStyle(document.documentElement).getPropertyValue("--primary-color"); 30 | const pointColor = lineColor; 31 | 32 | const svg = createSvgElement("svg", { 33 | width, 34 | height, 35 | viewBox: `0 0 ${width} ${height}`, 36 | }); 37 | 38 | const dates = [new Date(startAt), ...transactions.map((t) => new Date(t.createdAt))]; 39 | const maxDate = Math.max(...dates.map((date) => date.getTime())); 40 | const minDate = Math.min(new Date(startAt).getTime()); 41 | 42 | let sumAmount = 0; 43 | const sumAmounts = [0, ...transactions.map((t) => { 44 | sumAmount += t.amount; 45 | return sumAmount; 46 | })]; 47 | 48 | const maxAmount = Math.max(...sumAmounts); 49 | const minAmount = 0; 50 | 51 | const scales = { 52 | xScale: (date) => (date - minDate) / (maxDate - minDate) * (width - 2 * padding) + padding, 53 | yScale: (amount) => height - padding - (amount - minAmount) / (maxAmount - minAmount) * (height - 2 * padding), 54 | }; 55 | 56 | 57 | drawAxes(svg, width, height, padding, axisColor); 58 | drawGridlines(svg, width, height, padding, axisColor, maxAmount, minAmount, 5, "KB"); 59 | plotDataPoints(svg, transactions, sumAmounts, scales, { lineColor, pointColor }, startAt, height, padding); 60 | 61 | container.appendChild(svg); 62 | }; 63 | 64 | 65 | const plotDataPoints = (svg, transactions, sumAmounts, scales, colors, startAt, height, padding) => { 66 | let previousPoint = { x: scales.xScale(new Date(startAt).getTime()), y: height - padding }; 67 | 68 | transactions.forEach((transaction, index) => { 69 | const x = scales.xScale(new Date(transaction.createdAt).getTime()); 70 | const y = scales.yScale(sumAmounts[index + 1]); 71 | 72 | // Draw line from previous point 73 | const line = createSvgElement("line", { 74 | x1: previousPoint.x, 75 | y1: previousPoint.y, 76 | x2: x, 77 | y2: y, 78 | stroke: colors.lineColor, 79 | "stroke-width": "1", 80 | }); 81 | svg.appendChild(line); 82 | 83 | // Draw point 84 | const circle = createSvgElement("circle", { 85 | cx: x, 86 | cy: y, 87 | r: 3, 88 | fill: colors.pointColor, 89 | }); 90 | svg.appendChild(circle); 91 | 92 | // Add hover event to show transaction info 93 | addHoverEvent(circle, transaction, x, y); 94 | 95 | previousPoint = { x, y }; 96 | }); 97 | }; 98 | 99 | const getTransactionsData = async (name, token) => { 100 | try { 101 | const response = await fetchGraphQL(GET_TRANSACTIONS, { name }, token); 102 | const event = response.data.event[0].object.events[0]; 103 | const transactions = Array.isArray(response.data.transaction) ? response.data.transaction : []; 104 | return { 105 | startAt: event.startAt, 106 | endAt: event.endAt, 107 | transactions, 108 | }; 109 | } catch (error) { 110 | console.error("Error fetching transactions:", error); 111 | return null; 112 | } 113 | }; 114 | 115 | 116 | const addHoverEvent = (circle, transaction, x, y) => { 117 | const transactionInfo = document.getElementById("transaction-info"); 118 | 119 | circle.addEventListener("mouseover", () => { 120 | const date = new Date(transaction.createdAt); 121 | transactionInfo.style.display = "block"; 122 | transactionInfo.style.left = `${x + 150}px`; 123 | transactionInfo.style.top = `${y + 500}px`; 124 | transactionInfo.innerHTML = /*html*/` 125 |
${transaction.object.name}
126 |
127 |
Earned XP: ${transaction.amount / 1000} KB
128 |
Date: ${date.toDateString()}
129 |
130 | `; 131 | }); 132 | 133 | circle.addEventListener("mouseout", () => { 134 | transactionInfo.style.display = "none"; 135 | }); 136 | }; 137 | 138 | -------------------------------------------------------------------------------- /scripts/components/talents/talentsInfo.css: -------------------------------------------------------------------------------- 1 | /* Talents Info Styles */ 2 | #talents-info { 3 | min-width: 50%; 4 | background-color: var(--bg-color-light); 5 | border-radius: 10px; 6 | overflow: hidden; 7 | margin: 0 20px 20px; 8 | padding: 20px; 9 | } 10 | 11 | .talents-container { 12 | display: flex; 13 | flex-direction: column; 14 | gap: 25px; 15 | } 16 | 17 | .talents-title { 18 | text-align: center; 19 | margin: 0; 20 | color: var(--primary-color); 21 | font-size: 28px; 22 | font-weight: 700; 23 | } 24 | 25 | .project-name { 26 | text-align: center; 27 | padding: 10px; 28 | background: rgba(255, 255, 255, 0.05); 29 | border-radius: 8px; 30 | } 31 | 32 | .project-name p { 33 | margin: 0; 34 | color: var(--text-color-secondary); 35 | font-size: 16px; 36 | } 37 | 38 | .project-name span { 39 | color: var(--primary-color); 40 | font-weight: 600; 41 | } 42 | 43 | .members-grid { 44 | display: grid; 45 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 46 | gap: 20px; 47 | padding: 10px 0; 48 | } 49 | 50 | .member-card { 51 | display: flex; 52 | align-items: center; 53 | gap: 15px; 54 | padding: 20px; 55 | background: rgba(255, 255, 255, 0.05); 56 | border-radius: 10px; 57 | transition: all 0.3s ease; 58 | border: 2px solid transparent; 59 | cursor: pointer; 60 | } 61 | 62 | .member-card:hover { 63 | transform: translateY(-5px); 64 | background: rgba(255, 255, 255, 0.08); 65 | border-color: var(--primary-color); 66 | box-shadow: var(--primary-color) 0px 4px 15px; 67 | } 68 | 69 | .member-avatar { 70 | flex-shrink: 0; 71 | } 72 | 73 | .member-avatar img { 74 | width: 70px; 75 | height: 70px; 76 | border-radius: 50%; 77 | border: 3px solid var(--primary-color); 78 | object-fit: cover; 79 | transition: var(--transition); 80 | } 81 | 82 | .member-card:hover .member-avatar img { 83 | border-color: var(--text-color); 84 | transform: scale(1.05); 85 | } 86 | 87 | .member-info { 88 | display: flex; 89 | flex-direction: column; 90 | gap: 5px; 91 | min-width: 0; 92 | } 93 | 94 | .member-name { 95 | margin: 0; 96 | font-size: 18px; 97 | font-weight: 700; 98 | color: var(--text-color); 99 | white-space: nowrap; 100 | overflow: hidden; 101 | text-overflow: ellipsis; 102 | } 103 | 104 | .member-login { 105 | margin: 0; 106 | font-size: 14px; 107 | color: var(--primary-color); 108 | font-weight: 500; 109 | } 110 | 111 | .navigation-controls { 112 | display: flex; 113 | justify-content: space-between; 114 | align-items: center; 115 | padding: 20px 10px 10px; 116 | gap: 20px; 117 | } 118 | 119 | .nav-btn { 120 | display: flex; 121 | align-items: center; 122 | gap: 8px; 123 | background-color: var(--primary-color); 124 | color: var(--text-color); 125 | border: none; 126 | border-radius: 8px; 127 | padding: 12px 20px; 128 | font-size: 16px; 129 | font-weight: 600; 130 | cursor: pointer; 131 | transition: var(--transition); 132 | font-family: "Alegreya Sans", serif; 133 | } 134 | 135 | .nav-btn:hover { 136 | box-shadow: var(--primary-color) 1px 1px 10px; 137 | transform: scale(1.05); 138 | } 139 | 140 | .nav-btn:active { 141 | transform: scale(0.98); 142 | } 143 | 144 | .nav-btn i { 145 | font-size: 14px; 146 | } 147 | 148 | .group-counter { 149 | display: flex; 150 | align-items: center; 151 | gap: 10px; 152 | font-size: 18px; 153 | font-weight: 600; 154 | color: var(--text-color); 155 | padding: 10px 20px; 156 | background: rgba(255, 255, 255, 0.05); 157 | border-radius: 8px; 158 | } 159 | 160 | .page-input { 161 | width: 60px; 162 | text-align: center; 163 | font-size: 18px; 164 | font-weight: 600; 165 | color: var(--primary-color); 166 | background: transparent; 167 | border: 2px solid var(--primary-color); 168 | border-radius: 6px; 169 | padding: 5px; 170 | font-family: "Alegreya Sans", serif; 171 | transition: var(--transition); 172 | } 173 | 174 | .page-input:focus { 175 | outline: none; 176 | border-color: var(--text-color); 177 | box-shadow: var(--primary-color) 0px 0px 8px; 178 | } 179 | 180 | .page-input::-webkit-inner-spin-button, 181 | .page-input::-webkit-outer-spin-button { 182 | opacity: 1; 183 | } 184 | 185 | .page-separator { 186 | color: var(--text-color-secondary); 187 | } 188 | 189 | .total-pages { 190 | color: var(--primary-color); 191 | } 192 | 193 | .no-data { 194 | text-align: center; 195 | padding: 40px; 196 | color: var(--text-color-secondary); 197 | font-size: 18px; 198 | } 199 | 200 | /* Avatar Modal Styles */ 201 | .avatar-modal { 202 | display: none; 203 | position: fixed; 204 | z-index: 1000; 205 | left: 0; 206 | top: 0; 207 | width: 100%; 208 | height: 100%; 209 | background-color: rgba(0, 0, 0, 0.9); 210 | opacity: 0; 211 | transition: opacity 0.3s ease; 212 | } 213 | 214 | .avatar-modal.show { 215 | display: flex; 216 | justify-content: center; 217 | align-items: center; 218 | opacity: 1; 219 | } 220 | 221 | .modal-content { 222 | position: relative; 223 | display: flex; 224 | flex-direction: column; 225 | align-items: center; 226 | gap: 20px; 227 | background-color: var(--bg-color-light); 228 | padding: 40px; 229 | border-radius: 15px; 230 | max-width: 90%; 231 | max-height: 90%; 232 | animation: modalSlideIn 0.3s ease; 233 | } 234 | 235 | @keyframes modalSlideIn { 236 | from { 237 | transform: scale(0.8); 238 | opacity: 0; 239 | } 240 | to { 241 | transform: scale(1); 242 | opacity: 1; 243 | } 244 | } 245 | 246 | .modal-close { 247 | position: absolute; 248 | top: 15px; 249 | right: 20px; 250 | color: var(--text-color); 251 | font-size: 35px; 252 | font-weight: bold; 253 | cursor: pointer; 254 | transition: var(--transition); 255 | line-height: 1; 256 | } 257 | 258 | .modal-close:hover { 259 | color: var(--primary-color); 260 | transform: rotate(90deg); 261 | } 262 | 263 | #modal-avatar { 264 | width: 300px; 265 | height: 300px; 266 | border-radius: 50%; 267 | border: 5px solid var(--primary-color); 268 | object-fit: cover; 269 | box-shadow: var(--primary-color) 0px 0px 30px; 270 | } 271 | 272 | .modal-info { 273 | text-align: center; 274 | } 275 | 276 | .modal-info h3 { 277 | margin: 0 0 10px 0; 278 | font-size: 28px; 279 | font-weight: 700; 280 | color: var(--text-color); 281 | } 282 | 283 | .modal-info p { 284 | margin: 0; 285 | font-size: 20px; 286 | color: var(--primary-color); 287 | font-weight: 600; 288 | } 289 | 290 | /* Responsive Design */ 291 | @media (max-width: 768px) { 292 | .members-grid { 293 | grid-template-columns: 1fr; 294 | } 295 | 296 | .navigation-controls { 297 | flex-direction: column; 298 | gap: 15px; 299 | } 300 | 301 | .nav-btn { 302 | width: 100%; 303 | justify-content: center; 304 | } 305 | 306 | .talents-title { 307 | font-size: 24px; 308 | } 309 | 310 | #modal-avatar { 311 | width: 250px; 312 | height: 250px; 313 | } 314 | 315 | .modal-content { 316 | padding: 30px 20px; 317 | } 318 | 319 | .modal-info h3 { 320 | font-size: 24px; 321 | } 322 | 323 | .modal-info p { 324 | font-size: 18px; 325 | } 326 | } 327 | 328 | @media (max-width: 480px) { 329 | #modal-avatar { 330 | width: 200px; 331 | height: 200px; 332 | } 333 | 334 | .page-input { 335 | width: 50px; 336 | font-size: 16px; 337 | } 338 | } -------------------------------------------------------------------------------- /styles/profile.css: -------------------------------------------------------------------------------- 1 | .profile { 2 | width: 100%; 3 | padding-bottom: 1rem; 4 | } 5 | 6 | .profile-header { 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | padding: 15px 0; 11 | border-bottom: 1px solid var(--primary-color); 12 | background-color: var(--bg-color-light); 13 | margin-bottom: 20px; 14 | position: fixed; 15 | width: 100%; 16 | } 17 | 18 | .user-greeting { 19 | display: flex; 20 | flex-direction: column; 21 | gap: 5px; 22 | margin-left: 20px; 23 | } 24 | 25 | .user-greeting h1 { 26 | margin: 0; 27 | font-size: 24px; 28 | color: var(--primary-color); 29 | } 30 | 31 | .user-greeting .user-name { 32 | font-weight: bold; 33 | color: var(--secondary-color); 34 | } 35 | 36 | .user-greeting p { 37 | margin: 0; 38 | font-size: 14px; 39 | color: var(--text-color-secondary); 40 | } 41 | 42 | .logout-btn { 43 | display: flex; 44 | align-items: center; 45 | gap: 8px; 46 | background-color: var(--primary-color); 47 | color: #fff; 48 | border: none; 49 | border-radius: 8px; 50 | padding: 8px 15px; 51 | font-size: 14px; 52 | cursor: pointer; 53 | transition: all 0.2s ease; 54 | margin-right: 20px; 55 | } 56 | 57 | .logout-btn i { 58 | font-size: 16px; 59 | } 60 | 61 | .logout-btn:hover { 62 | background-color: var(--primary-color-dark); 63 | transform: scale(1.05); 64 | } 65 | 66 | .profile-container { 67 | padding-top: 6.5rem; 68 | } 69 | 70 | #audits-info { 71 | min-width: 50%; 72 | background-color: var(--bg-color-light); 73 | border-radius: 10px; 74 | overflow: hidden; 75 | margin: 0 20px 20px; 76 | } 77 | 78 | .audits-title { 79 | text-align: center; 80 | margin: 15px 0; 81 | color: var(--text-color); 82 | } 83 | 84 | .audits-flex { 85 | display: flex; 86 | flex-wrap: wrap; 87 | gap: 20px; 88 | padding: 20px; 89 | justify-content: center; 90 | } 91 | 92 | .audits-grid { 93 | display: flex; 94 | justify-content: space-between; 95 | flex-wrap: wrap; 96 | gap: 30px; 97 | padding: 20px; 98 | } 99 | 100 | .audit-card { 101 | display: flex; 102 | flex-direction: column; 103 | align-items: center; 104 | padding: 20px; 105 | background: rgba(255, 255, 255, 0.05); 106 | border-radius: 10px; 107 | transition: transform 0.2s; 108 | flex: 1; 109 | min-width: 200px; 110 | max-width: 250px; 111 | } 112 | 113 | .audit-card:hover { 114 | transform: translateY(-5px); 115 | } 116 | 117 | .audit-number { 118 | font-size: 2.5em; 119 | font-weight: bold; 120 | color: var(--primary-color); 121 | } 122 | 123 | .audit-label { 124 | margin-top: 10px; 125 | color: var(--text-color-secondary); 126 | font-size: 1.1em; 127 | } 128 | 129 | .warning .audit-number { 130 | color: #ff6b6b; 131 | } 132 | 133 | .level { 134 | display: flex; 135 | align-items: center; 136 | justify-content: space-between; 137 | margin: 0 20px 20px; 138 | } 139 | 140 | #level-info { 141 | min-height: 210px; 142 | width: 30%; 143 | background-color: var(--bg-color-light); 144 | border-radius: 10px; 145 | overflow: hidden; 146 | } 147 | 148 | .level-title { 149 | text-align: center; 150 | margin: 15px 0; 151 | color: var(--text-color); 152 | } 153 | 154 | .level-info-container { 155 | display: flex; 156 | gap: 20px; 157 | padding: 20px; 158 | justify-content: center; 159 | align-items: center; 160 | border-radius: 50%; 161 | background-color: var(--primary-color); 162 | width: 80px; 163 | height: 80px; 164 | margin: 1rem auto; 165 | } 166 | 167 | .level-info-container { 168 | font-size: 40px; 169 | font-weight: 800; 170 | letter-spacing: 2px; 171 | } 172 | 173 | #last-transactions-info { 174 | min-height: 210px; 175 | width: 68%; 176 | background-color: var(--bg-color-light); 177 | border-radius: 10px; 178 | overflow: hidden; 179 | } 180 | 181 | .transaction-item { 182 | display: flex; 183 | align-items: center; 184 | justify-content: space-between; 185 | padding: 10px 20px; 186 | } 187 | 188 | .transaction-item .name { 189 | display: inline-block; 190 | min-width: 300px; 191 | } 192 | 193 | .transaction-item .amount { 194 | font-weight: 600; 195 | letter-spacing: 1px; 196 | } 197 | 198 | #transactions-chart { 199 | min-width: 90%; 200 | background-color: var(--bg-color-light); 201 | border-radius: 10px; 202 | overflow: hidden; 203 | margin: 0 20px 20px; 204 | 205 | } 206 | 207 | .transactions-chart-info { 208 | display: flex; 209 | flex-direction: column; 210 | align-items: center; 211 | justify-content: center; 212 | margin-top: 10px; 213 | 214 | } 215 | 216 | svg { 217 | width: 100%; 218 | } 219 | 220 | #transaction-info { 221 | position: absolute; 222 | 223 | /* width: 250px; */ 224 | background-color: var(--bg-color-light); 225 | color: var(--text-color); 226 | border-radius: 10px; 227 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 228 | display: none; 229 | z-index: 100; 230 | overflow: hidden; 231 | font-size: 14px; 232 | border: 1px solid rgba(255, 255, 255, 0.1); 233 | transition: var(--transition); 234 | } 235 | 236 | #transaction-info .transaction-header { 237 | background-color: rgba(255, 255, 255, 0.05); 238 | padding: 10px; 239 | font-weight: 500; 240 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 241 | color: var(--primary-color); 242 | } 243 | 244 | #transaction-info .transaction-details { 245 | padding: 10px; 246 | } 247 | 248 | #transaction-info .transaction-details div { 249 | margin-bottom: 5px; 250 | } 251 | 252 | #transaction-info .transaction-details strong { 253 | color: var(--text-color-secondary); 254 | margin-right: 5px; 255 | } 256 | 257 | #skills-chart { 258 | min-width: 90%; 259 | /* min-height: 80vh; */ 260 | background-color: var(--bg-color-light); 261 | border-radius: 10px; 262 | overflow: hidden; 263 | margin: 0 20px 20px; 264 | } 265 | 266 | .skills-chart-info { 267 | display: flex; 268 | flex-direction: column; 269 | align-items: center; 270 | justify-content: center; 271 | margin-top: 10px; 272 | } 273 | 274 | .chart-border { 275 | height: 3px; 276 | width: 100%; 277 | background-color: var(--primary-color); 278 | } 279 | 280 | @media screen and (max-width: 820px) { 281 | .level { 282 | flex-direction: column; 283 | gap: 20px; 284 | } 285 | 286 | #level-info { 287 | width: 50%; 288 | } 289 | 290 | #last-transactions-info { 291 | width: 100%; 292 | 293 | } 294 | } 295 | 296 | 297 | @media screen and (max-width: 564px) { 298 | #level-info { 299 | width: 70%; 300 | } 301 | 302 | .audits-grid { 303 | flex-direction: column; 304 | gap: 20px; 305 | align-items: center; 306 | } 307 | .audit-card{ 308 | min-width: 80%; 309 | } 310 | .transaction-item .name{ 311 | min-width: 185px; 312 | font-size: 13px; 313 | } 314 | 315 | .transaction-item .amount{ 316 | font-size: 13px; 317 | } 318 | .transaction-item .date{ 319 | font-size: 13px; 320 | } 321 | .profile-container { 322 | padding-top: 8rem; 323 | } 324 | } 325 | 326 | 327 | @media screen and (max-width: 382px) { 328 | #level-info { 329 | width: 100%; 330 | } 331 | svg{ 332 | height: 300px; 333 | } 334 | .transaction-item{ 335 | padding: 10px 5px; 336 | } 337 | .transaction-item .name{ 338 | min-width: 175px; 339 | font-size: 12px; 340 | } 341 | 342 | .transaction-item .amount{ 343 | font-size: 12px; 344 | } 345 | .transaction-item .date{ 346 | font-size: 12px; 347 | } 348 | } -------------------------------------------------------------------------------- /scripts/components/talents/talentInfo.js: -------------------------------------------------------------------------------- 1 | import { GET_RANDOM_USERS } from "../../api/graphql.js"; 2 | import { fetchGraphQL } from "../../api/graphqlRequests.js"; 3 | import { handleLogout } from "../../app/handleAuth.js"; 4 | 5 | let currentGroupIndex = 0; 6 | let groupsData = []; 7 | 8 | export const renderTalentInfo = async () => { 9 | const token = localStorage.getItem("JWT"); 10 | let data; 11 | 12 | await fetchGraphQL(GET_RANDOM_USERS, {}, token) 13 | .then((response) => { 14 | if (Array.isArray(response.errors)) { 15 | throw response.errors[0].message; 16 | } 17 | 18 | data = response?.data.group; 19 | console.log(data); 20 | 21 | if (!response && typeof data !== 'object') { 22 | throw new Error("Invalid data received!"); 23 | } 24 | }) 25 | .catch((error) => { 26 | if (typeof error === "string" && error.includes('JWTExpired')) handleLogout(); 27 | console.error(error); 28 | }); 29 | 30 | groupsData = data || []; 31 | 32 | if (groupsData.length > 0) { 33 | groupsData = shuffleArray(groupsData); 34 | renderCurrentGroup(); 35 | } else { 36 | const container = document.getElementById("talents-info"); 37 | container.innerHTML = /*html*/ ` 38 |
39 |

No group data available

40 |
41 | `; 42 | } 43 | }; 44 | 45 | const shuffleArray = (array) => { 46 | const shuffled = [...array]; 47 | for (let i = shuffled.length - 1; i > 0; i--) { 48 | const j = Math.floor(Math.random() * (i + 1)); 49 | [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 50 | } 51 | return shuffled; 52 | }; 53 | 54 | const renderCurrentGroup = () => { 55 | const container = document.getElementById("talents-info"); 56 | const currentGroup = groupsData[currentGroupIndex]; 57 | 58 | container.innerHTML = /*html*/ ` 59 |
60 |

Group Members

61 |
62 |

Project: ${currentGroup.object.name}

63 |
64 | 65 |
66 | ${currentGroup.members.map((member, index) => ` 67 |
68 |
69 | ${member.user.firstName} ${member.user.lastName} 70 |
71 |
72 |

${member.user.firstName} ${member.user.lastName}

73 | 74 |
75 |
76 | `).join('')} 77 |
78 | 79 | 101 |
102 | 103 |
104 | 112 |
113 | `; 114 | 115 | attachNavigationListeners(); 116 | attachMemberClickListeners(); 117 | attachModalListeners(); 118 | attachPageInputListener(); 119 | }; 120 | 121 | const attachNavigationListeners = () => { 122 | const prevBtn = document.getElementById("prev-btn"); 123 | const nextBtn = document.getElementById("next-btn"); 124 | 125 | prevBtn.addEventListener("click", navigatePrevious); 126 | nextBtn.addEventListener("click", navigateNext); 127 | }; 128 | 129 | const attachPageInputListener = () => { 130 | const pageInput = document.getElementById("page-input"); 131 | 132 | pageInput.addEventListener("keypress", (e) => { 133 | if (e.key === "Enter") { 134 | const value = parseInt(pageInput.value); 135 | if (value >= 1 && value <= groupsData.length) { 136 | currentGroupIndex = value - 1; 137 | renderCurrentGroup(); 138 | } else { 139 | pageInput.value = currentGroupIndex + 1; 140 | } 141 | } 142 | }); 143 | 144 | pageInput.addEventListener("blur", () => { 145 | const value = parseInt(pageInput.value); 146 | if (!value || value < 1 || value > groupsData.length) { 147 | pageInput.value = currentGroupIndex + 1; 148 | } else { 149 | currentGroupIndex = value - 1; 150 | renderCurrentGroup(); 151 | } 152 | }); 153 | 154 | pageInput.addEventListener("focus", function() { 155 | this.select(); 156 | }); 157 | }; 158 | 159 | const attachMemberClickListeners = () => { 160 | const memberCards = document.querySelectorAll(".member-card"); 161 | const currentGroup = groupsData[currentGroupIndex]; 162 | 163 | memberCards.forEach((card, index) => { 164 | card.addEventListener("click", () => { 165 | const member = currentGroup.members[index]; 166 | openAvatarModal(member); 167 | }); 168 | }); 169 | }; 170 | 171 | const openAvatarModal = (member) => { 172 | const modal = document.getElementById("avatar-modal"); 173 | const modalAvatar = document.getElementById("modal-avatar"); 174 | const modalName = document.getElementById("modal-name"); 175 | const modalLogin = document.getElementById("modal-login"); 176 | 177 | modalAvatar.src = member.user.avatarUrl; 178 | modalAvatar.alt = `${member.user.firstName} ${member.user.lastName}`; 179 | modalName.textContent = `${member.user.firstName} ${member.user.lastName}`; 180 | modalLogin.textContent = `@${member.user.login}`; 181 | 182 | modal.classList.add("show"); 183 | document.body.style.overflow = "hidden"; 184 | }; 185 | 186 | const closeAvatarModal = () => { 187 | const modal = document.getElementById("avatar-modal"); 188 | modal.classList.remove("show"); 189 | document.body.style.overflow = "auto"; 190 | }; 191 | 192 | const attachModalListeners = () => { 193 | const modal = document.getElementById("avatar-modal"); 194 | const closeBtn = document.querySelector(".modal-close"); 195 | 196 | closeBtn.addEventListener("click", closeAvatarModal); 197 | 198 | modal.addEventListener("click", (e) => { 199 | if (e.target === modal) { 200 | closeAvatarModal(); 201 | } 202 | }); 203 | 204 | document.addEventListener("keydown", (e) => { 205 | if (e.key === "Escape" && modal.classList.contains("show")) { 206 | closeAvatarModal(); 207 | } 208 | }); 209 | }; 210 | 211 | const navigatePrevious = () => { 212 | if (groupsData.length === 0) return; 213 | 214 | currentGroupIndex = (currentGroupIndex - 1 + groupsData.length) % groupsData.length; 215 | renderCurrentGroup(); 216 | }; 217 | 218 | const navigateNext = () => { 219 | if (groupsData.length === 0) return; 220 | 221 | currentGroupIndex = (currentGroupIndex + 1) % groupsData.length; 222 | renderCurrentGroup(); 223 | }; 224 | 225 | export { navigatePrevious, navigateNext }; --------------------------------------------------------------------------------