├── 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 |
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 |
26 |
27 |
28 |
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 |
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 |

70 |
71 |
72 |
${member.user.firstName} ${member.user.lastName}
73 |
@${member.user.login}
74 |
75 |
76 | `).join('')}
77 |
78 |
79 |
80 |
84 |
85 |
93 | /
94 | ${groupsData.length}
95 |
96 |
100 |
101 |
102 |
103 |
104 |
105 |
×
106 |
![]()
107 |
111 |
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 };
--------------------------------------------------------------------------------