Imagery imported for icons and items. Live updates working.
Hover tooltip working. Features.md added for better tracking.
This commit is contained in:
@@ -16,6 +16,7 @@ import Statistics from './pages/Statistics';
|
||||
import Calculators from './pages/Calculators';
|
||||
import Faq from './pages/Faq';
|
||||
import ViewCharacter from './pages/ViewCharacter';
|
||||
import Groups from './pages/Groups';
|
||||
import { submitRenderError } from './client/feedback-client';
|
||||
import { ErrorPage } from './components/common/util/ErrorBoundary';
|
||||
|
||||
@@ -71,6 +72,7 @@ export default function App() {
|
||||
<Route path='calculators' element={<Calculators />}>
|
||||
<Route path=':skill' element={<Calculators />} />
|
||||
</Route>
|
||||
<Route path='groups' element={<Groups />} />
|
||||
<Route path='about' element={<About />} />
|
||||
<Route path='settings' element={<Settings />} />
|
||||
<Route path='faq' element={<Faq />} />
|
||||
|
||||
316
os-league-tools-master/src/client/group-ironmen-client.js
Normal file
316
os-league-tools-master/src/client/group-ironmen-client.js
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* API Client for Group Ironmen Backend
|
||||
* Communicates with the Java/Spring Boot backend at spring-backend/
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || 'http://localhost:8080';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
function defaultResponseHandler(result) {
|
||||
return {
|
||||
success: true,
|
||||
value: result,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultErrorHandler(error) {
|
||||
console.warn('Group Ironmen API Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'An error occurred',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group with initial members
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string[]} memberNames - Array of member names
|
||||
* @returns {Promise<{success: boolean, value?: {groupId: number, token: string}, error?: string}>}
|
||||
*/
|
||||
export async function createGroup(groupName, memberNames) {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/create-group`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
body: JSON.stringify({
|
||||
name: groupName,
|
||||
member_names: memberNames,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || `Failed to create group: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return defaultErrorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group data with optional delta updates
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string} token - Authentication token
|
||||
* @param {number} fromTime - Optional timestamp for delta updates (epoch millis)
|
||||
* @returns {Promise<{success: boolean, value?: Array, error?: string}>}
|
||||
*/
|
||||
export async function getGroupData(groupName, token, fromTime = null) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(groupName);
|
||||
const url = fromTime
|
||||
? `${BASE_URL}/api/group/${encodedName}/get-group-data?from_time=${fromTime}`
|
||||
: `${BASE_URL}/api/group/${encodedName}/get-group-data`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || `Failed to get group data: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return defaultErrorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a group member's data (typically called by RuneLite plugin)
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string} token - Authentication token
|
||||
* @param {Object} memberData - Member update data
|
||||
* @returns {Promise<{success: boolean, value?: Object, error?: string}>}
|
||||
*/
|
||||
export async function updateGroupMember(groupName, token, memberData) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(groupName);
|
||||
const response = await fetch(`${BASE_URL}/api/group/${encodedName}/update-group-member`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: token,
|
||||
},
|
||||
body: JSON.stringify(memberData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || `Failed to update member: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return defaultErrorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new member to the group
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string} token - Authentication token
|
||||
* @param {string} memberName - Name of the member to add
|
||||
* @returns {Promise<{success: boolean, value?: Object, error?: string}>}
|
||||
*/
|
||||
export async function addGroupMember(groupName, token, memberName) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(groupName);
|
||||
const response = await fetch(`${BASE_URL}/api/group/${encodedName}/add-group-member`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: token,
|
||||
},
|
||||
body: JSON.stringify({ name: memberName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || `Failed to add member: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return defaultErrorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a member from the group
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string} token - Authentication token
|
||||
* @param {string} memberName - Name of the member to delete
|
||||
* @returns {Promise<{success: boolean, value?: Object, error?: string}>}
|
||||
*/
|
||||
export async function deleteGroupMember(groupName, token, memberName) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(groupName);
|
||||
const response = await fetch(`${BASE_URL}/api/group/${encodedName}/delete-group-member`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: token,
|
||||
},
|
||||
body: JSON.stringify({ name: memberName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || `Failed to delete member: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return defaultErrorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a group member
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string} token - Authentication token
|
||||
* @param {string} oldName - Current member name
|
||||
* @param {string} newName - New member name
|
||||
* @returns {Promise<{success: boolean, value?: Object, error?: string}>}
|
||||
*/
|
||||
export async function renameGroupMember(groupName, token, oldName, newName) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(groupName);
|
||||
const response = await fetch(`${BASE_URL}/api/group/${encodedName}/rename-group-member`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_name: oldName,
|
||||
new_name: newName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || `Failed to rename member: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return defaultErrorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached Grand Exchange prices
|
||||
* @returns {Promise<{success: boolean, value?: Object, error?: string}>}
|
||||
*/
|
||||
export async function getGePrices() {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/ge-prices`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || `Failed to get GE prices: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return defaultErrorHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token is valid
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string} token - Authentication token
|
||||
* @returns {Promise<{success: boolean, value?: boolean, error?: string}>}
|
||||
*/
|
||||
export async function amILoggedIn(groupName, token) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(groupName);
|
||||
const response = await fetch(`${BASE_URL}/api/group/${encodedName}/am-i-logged-in`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, value: false };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return { success: false, value: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a member exists in the group
|
||||
* @param {string} groupName - Name of the group
|
||||
* @param {string} token - Authentication token
|
||||
* @param {string} memberName - Name of the member to check
|
||||
* @returns {Promise<{success: boolean, value?: boolean, error?: string}>}
|
||||
*/
|
||||
export async function amIInGroup(groupName, token, memberName) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(groupName);
|
||||
const response = await fetch(`${BASE_URL}/api/group/${encodedName}/am-i-in-group?member=${encodeURIComponent(memberName)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, value: false };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return defaultResponseHandler(result);
|
||||
} catch (error) {
|
||||
return { success: false, value: false };
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const LOCALSTORAGE_KEYS = {
|
||||
CHARACTER: 'character',
|
||||
CALCULATORS: 'calculators',
|
||||
ACCOUNT: 'account',
|
||||
GROUP: 'group',
|
||||
};
|
||||
|
||||
export const SESSIONSTORAGE_KEYS = {};
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function PageWrapper({ children }) {
|
||||
new NavItem('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
|
||||
new NavItem('Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
|
||||
new NavItem('Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
|
||||
new NavItem('Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
|
||||
new NavItem('Character', 'secondary', 1, 0).withCustomRenderFn(
|
||||
() => <Character.NavBarItem key='character' setCharacterModalOpen={setCharacterModalOpen} />,
|
||||
() => <Character.CollapsedMenu key='character' setCharacterModalOpen={setCharacterModalOpen} />
|
||||
|
||||
58
os-league-tools-master/src/components/common/Tooltip.js
Normal file
58
os-league-tools-master/src/components/common/Tooltip.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* RuneScape-style tooltip component
|
||||
* Follows mouse cursor and displays formatted text
|
||||
*/
|
||||
export default function Tooltip({ content, visible, mousePosition }) {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [tooltipHeight, setTooltipHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && mousePosition) {
|
||||
const { x, y } = mousePosition;
|
||||
|
||||
// Position above cursor
|
||||
const top = Math.max(0, y - tooltipHeight);
|
||||
|
||||
// Position to right of cursor, but flip to left if past halfway point
|
||||
let left = x + 2;
|
||||
if (left >= window.innerWidth / 2) {
|
||||
left = x - 2; // Will be adjusted by transform for actual width
|
||||
}
|
||||
|
||||
setPosition({ x: left, y: top });
|
||||
}
|
||||
}, [mousePosition, visible, tooltipHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
// Measure tooltip height after render
|
||||
const tooltip = document.getElementById('rs-tooltip');
|
||||
if (tooltip) {
|
||||
setTooltipHeight(tooltip.offsetHeight);
|
||||
}
|
||||
}, [content, visible]);
|
||||
|
||||
if (!visible || !content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = {
|
||||
position: 'fixed',
|
||||
top: `${position.y}px`,
|
||||
left: `${position.x}px`,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
padding: '4px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10000,
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'nowrap',
|
||||
transform: position.x >= window.innerWidth / 2 ? 'translateX(-100%)' : 'translateX(0)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="rs-tooltip" style={style} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
);
|
||||
}
|
||||
383
os-league-tools-master/src/components/group/GroupComponents.css
Normal file
383
os-league-tools-master/src/components/group/GroupComponents.css
Normal file
@@ -0,0 +1,383 @@
|
||||
/* Group Components Styling - Matching original group-ironmen-master design */
|
||||
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
--primary-text: white;
|
||||
--elevated: rgba(255, 255, 255, 0.08);
|
||||
--light-border: rgba(255, 255, 255, 0.12);
|
||||
--darken: rgba(0, 0, 0, 0.2);
|
||||
--background: #000000;
|
||||
--black: #000000;
|
||||
--invalid: #ee0d0d;
|
||||
--orange: #ff981f;
|
||||
--yellow: #ffff00;
|
||||
--red: #ff0000;
|
||||
--green: #0dc10d;
|
||||
}
|
||||
|
||||
/* Player Stats Component */
|
||||
.player-stats {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.player-stats__name {
|
||||
margin-left: 8px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.player-stats__world {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.player-stats__prayer-numbers,
|
||||
.player-stats__hitpoints-numbers {
|
||||
margin-left: 8px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-stats__prayer,
|
||||
.player-stats__hitpoints,
|
||||
.player-stats__energy {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.player-stats__energy {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
/* Stat Bar Component */
|
||||
.stat-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-bar__fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Equipment Component */
|
||||
.player-equipment {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.equipment {
|
||||
position: relative;
|
||||
width: 175px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.equipment-slot {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
/* Equipment slot positioning */
|
||||
.equipment-head { left: 70px; top: 0; }
|
||||
.equipment-cape { left: 29px; top: 39px; }
|
||||
.equipment-neck { left: 70px; top: 39px; }
|
||||
.equipment-ammo { left: 111px; top: 39px; }
|
||||
.equipment-weapon { left: 14px; top: 78px; }
|
||||
.equipment-torso { left: 70px; top: 78px; }
|
||||
.equipment-shield { left: 126px; top: 78px; }
|
||||
.equipment-legs { left: 70px; top: 118px; }
|
||||
.equipment-gloves { left: 14px; top: 158px; }
|
||||
.equipment-boots { left: 70px; top: 158px; }
|
||||
.equipment-ring { left: 126px; top: 158px; }
|
||||
|
||||
/* Inventory Component */
|
||||
.player-inventory {
|
||||
--inventory-height: 254px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
min-width: 190px;
|
||||
height: var(--inventory-height);
|
||||
}
|
||||
|
||||
.player-inventory__inventory {
|
||||
padding-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 40px);
|
||||
grid-template-rows: repeat(7, 34px);
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.player-inventory__background {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 190px;
|
||||
height: var(--inventory-height);
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Item Box Component */
|
||||
.item-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: optimizequality;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.item-box__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.item-box__image {
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(2px);
|
||||
height: 100%;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: optimizequality;
|
||||
}
|
||||
|
||||
.item-box__quantity {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
color: yellow;
|
||||
transform: translate(0, -20%);
|
||||
font-size: 16px;
|
||||
text-shadow: 1px 1px var(--black);
|
||||
}
|
||||
|
||||
/* Skill Box Component */
|
||||
.skill-box {
|
||||
display: flex;
|
||||
position: relative;
|
||||
transform: translateZ(1px);
|
||||
width: 62px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.skill-box__left {
|
||||
/* Background applied via inline style */
|
||||
}
|
||||
|
||||
.skill-box__right {
|
||||
position: relative;
|
||||
/* Background applied via inline style */
|
||||
}
|
||||
|
||||
.skill-box__left,
|
||||
.skill-box__right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 31px;
|
||||
height: 32px;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.skill-box__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.skill-box__current-level,
|
||||
.skill-box__baseline-level {
|
||||
position: absolute;
|
||||
font-size: 13px;
|
||||
color: yellow;
|
||||
text-shadow: 1px 1px var(--black);
|
||||
min-width: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.skill-box__baseline-level {
|
||||
transform: translate(calc(50% - 1px), 4px);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.skill-box__current-level {
|
||||
transform: translate(calc(-50% - 2px), -4px);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.skill-box__progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.skill-box__progress-bar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-origin: left;
|
||||
transition: width 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* Player Skills Grid */
|
||||
.player-skills {
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.player-skills__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--light-border);
|
||||
}
|
||||
|
||||
.player-skills__header-text {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.player-skills__header-value {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.player-skills__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 62px);
|
||||
gap: 4px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.player-skills__skill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.player-skills__skill-name {
|
||||
font-size: 9px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 62px;
|
||||
}
|
||||
|
||||
/* Image rendering for pixelated assets */
|
||||
.pixelated {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
}
|
||||
|
||||
/* XP Dropper Component */
|
||||
.xp-dropper {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateY(0%);
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.xp-dropper__drop {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 0;
|
||||
height: max-content;
|
||||
animation: scroll 4s linear;
|
||||
animation-fill-mode: forwards;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.xp-dropper__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.xp-dropper__skill-icon {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.xp-dropper__text {
|
||||
color: #00ff00;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
33
os-league-tools-master/src/components/group/ItemBox.js
Normal file
33
os-league-tools-master/src/components/group/ItemBox.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import Item from '../../models/Item';
|
||||
import './GroupComponents.css';
|
||||
|
||||
/**
|
||||
* ItemBox component - displays a single item with quantity
|
||||
* Matches the original group-ironmen-master design
|
||||
*/
|
||||
export default function ItemBox({ item }) {
|
||||
if (!item || !(item instanceof Item) || !item.isValid()) {
|
||||
// Empty/blank slot - no visual
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='item-box'>
|
||||
<div className='item-box__container'>
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.name || 'Item'}
|
||||
className='item-box__image'
|
||||
loading='lazy'
|
||||
/>
|
||||
|
||||
{item.quantity > 1 && (
|
||||
<span className='item-box__quantity'>
|
||||
{item.shortQuantity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import ItemBox from './ItemBox';
|
||||
import Item from '../../models/Item';
|
||||
import './GroupComponents.css';
|
||||
|
||||
/**
|
||||
* PlayerEquipment component - displays equipped items
|
||||
* Matches the original group-ironmen-master design with exact slot positioning
|
||||
*/
|
||||
export default function PlayerEquipment({ member }) {
|
||||
if (!member || !member.equipment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse equipment items
|
||||
const items = Item.parseItemData(member.equipment);
|
||||
|
||||
// Equipment items are in order: head, cape, neck, ammo, weapon, torso, shield, legs, gloves, boots, ring
|
||||
const equipment = {
|
||||
head: items[0] || new Item(0, 0),
|
||||
cape: items[1] || new Item(0, 0),
|
||||
neck: items[2] || new Item(0, 0),
|
||||
ammo: items[3] || new Item(0, 0),
|
||||
weapon: items[4] || new Item(0, 0),
|
||||
torso: items[5] || new Item(0, 0),
|
||||
shield: items[6] || new Item(0, 0),
|
||||
legs: items[7] || new Item(0, 0),
|
||||
gloves: items[9] || new Item(0, 0),
|
||||
boots: items[10] || new Item(0, 0),
|
||||
ring: items[12] || new Item(0, 0),
|
||||
};
|
||||
|
||||
const slotStyle = { backgroundImage: 'url(/ui/170-0.png)' };
|
||||
|
||||
return (
|
||||
<div className='player-equipment'>
|
||||
<div className='equipment'>
|
||||
<div className='equipment-slot equipment-head' style={slotStyle}>
|
||||
<ItemBox item={equipment.head} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-cape' style={slotStyle}>
|
||||
<ItemBox item={equipment.cape} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-neck' style={slotStyle}>
|
||||
<ItemBox item={equipment.neck} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-ammo' style={slotStyle}>
|
||||
<ItemBox item={equipment.ammo} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-weapon' style={slotStyle}>
|
||||
<ItemBox item={equipment.weapon} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-torso' style={slotStyle}>
|
||||
<ItemBox item={equipment.torso} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-shield' style={slotStyle}>
|
||||
<ItemBox item={equipment.shield} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-legs' style={slotStyle}>
|
||||
<ItemBox item={equipment.legs} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-gloves' style={slotStyle}>
|
||||
<ItemBox item={equipment.gloves} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-boots' style={slotStyle}>
|
||||
<ItemBox item={equipment.boots} />
|
||||
</div>
|
||||
<div className='equipment-slot equipment-ring' style={slotStyle}>
|
||||
<ItemBox item={equipment.ring} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import ItemBox from './ItemBox';
|
||||
import Item from '../../models/Item';
|
||||
import './GroupComponents.css';
|
||||
|
||||
/**
|
||||
* PlayerInventory component - displays inventory in 4x7 grid (28 slots)
|
||||
* Matches the original group-ironmen-master design with background image
|
||||
*/
|
||||
export default function PlayerInventory({ member }) {
|
||||
if (!member || !member.inventory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse inventory items
|
||||
const items = Item.parseItemData(member.inventory);
|
||||
|
||||
// Ensure we have exactly 28 slots
|
||||
const inventorySlots = Array(28)
|
||||
.fill(null)
|
||||
.map((_, i) => items[i] || new Item(0, 0));
|
||||
|
||||
return (
|
||||
<div className='player-inventory'>
|
||||
<div
|
||||
className='player-inventory__background'
|
||||
style={{ backgroundImage: 'url(/ui/inventory_background.png)' }}
|
||||
/>
|
||||
<div className='player-inventory__inventory'>
|
||||
{inventorySlots.map((item, index) => (
|
||||
<ItemBox key={index} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
os-league-tools-master/src/components/group/PlayerSkills.js
Normal file
114
os-league-tools-master/src/components/group/PlayerSkills.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import SkillBox from './SkillBox';
|
||||
import { Skill } from '../../models/Skill';
|
||||
import Tooltip from '../common/Tooltip';
|
||||
import './GroupComponents.css';
|
||||
|
||||
// RuneLite skill tab display order (matches in-game skill interface)
|
||||
const DISPLAY_ORDER = [
|
||||
'Attack',
|
||||
'Hitpoints',
|
||||
'Mining',
|
||||
'Strength',
|
||||
'Agility',
|
||||
'Smithing',
|
||||
'Defence',
|
||||
'Herblore',
|
||||
'Fishing',
|
||||
'Ranged',
|
||||
'Thieving',
|
||||
'Cooking',
|
||||
'Prayer',
|
||||
'Crafting',
|
||||
'Firemaking',
|
||||
'Magic',
|
||||
'Fletching',
|
||||
'Woodcutting',
|
||||
'Runecraft',
|
||||
'Slayer',
|
||||
'Farming',
|
||||
'Construction',
|
||||
'Hunter',
|
||||
];
|
||||
|
||||
/**
|
||||
* PlayerSkills component - displays all skills in a grid layout
|
||||
* Matches the original group-ironmen-master design
|
||||
*/
|
||||
export default function PlayerSkills({ member }) {
|
||||
const [tooltip, setTooltip] = useState({ visible: false, content: '', mousePosition: null });
|
||||
|
||||
if (!member || !member.skills) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse skills from API data
|
||||
const skills = Skill.parseSkillData(member.skills);
|
||||
|
||||
// Calculate combat level and total level
|
||||
const combatLevel = Skill.calculateCombatLevel(skills);
|
||||
const totalLevel = Skill.calculateTotalLevel(skills);
|
||||
|
||||
const handleMouseEnter = (content, event) => {
|
||||
setTooltip({
|
||||
visible: true,
|
||||
content,
|
||||
mousePosition: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
setTooltip(prev => ({
|
||||
...prev,
|
||||
mousePosition: { x: event.clientX, y: event.clientY },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setTooltip({ visible: false, content: '', mousePosition: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='player-skills'>
|
||||
{/* Header with totals */}
|
||||
<div className='player-skills__header'>
|
||||
<div>
|
||||
<span className='player-skills__header-text'>Combat: </span>
|
||||
<span className='player-skills__header-value'>{combatLevel}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='player-skills__header-text'>Total: </span>
|
||||
<span className='player-skills__header-value'>{totalLevel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills grid - 3 columns matching RuneLite skill tab order */}
|
||||
<div className='player-skills__grid'>
|
||||
{DISPLAY_ORDER.map(skillName => {
|
||||
const skill = skills[skillName];
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={skillName} className='player-skills__skill'>
|
||||
<SkillBox
|
||||
skill={skill}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
<span className='player-skills__skill-name'>{skillName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<Tooltip
|
||||
content={tooltip.content}
|
||||
visible={tooltip.visible}
|
||||
mousePosition={tooltip.mousePosition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
os-league-tools-master/src/components/group/PlayerStats.js
Normal file
61
os-league-tools-master/src/components/group/PlayerStats.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import StatBar from './StatBar';
|
||||
import { timeSinceLastUpdate } from '../../utils/formatters';
|
||||
import './GroupComponents.css';
|
||||
|
||||
/**
|
||||
* PlayerStats component - displays HP, Prayer, Energy bars and world/status
|
||||
* Matches the original group-ironmen-master design
|
||||
*/
|
||||
export default function PlayerStats({ member }) {
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { stats = {} } = member;
|
||||
const hp = stats.hitpoints || { current: 10, max: 10 };
|
||||
const prayer = stats.prayer || { current: 1, max: 1 };
|
||||
const energy = stats.energy || { current: 10000, max: 10000 };
|
||||
const { world } = stats;
|
||||
|
||||
// Check if player is inactive (>5 min since last update)
|
||||
const lastUpdated = member.last_updated ? new Date(member.last_updated) : null;
|
||||
const isInactive =
|
||||
lastUpdated && Date.now() - lastUpdated.getTime() > 5 * 60 * 1000;
|
||||
|
||||
return (
|
||||
<div className='player-stats'>
|
||||
{/* HP Bar */}
|
||||
<div className='player-stats__hitpoints'>
|
||||
<StatBar current={hp.current} max={hp.max} color='#157145' className='player-stats__hitpoints-bar' />
|
||||
<div className='player-stats__name'>
|
||||
{member.name} - {' '}
|
||||
<span className='player-stats__world'>
|
||||
{isInactive ? `Inactive: ${lastUpdated && timeSinceLastUpdate(lastUpdated)}` : (world ? `W${world}` : 'Online')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='player-stats__hitpoints-numbers'>
|
||||
{hp.current} / {hp.max}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prayer Bar */}
|
||||
<div className='player-stats__prayer'>
|
||||
<StatBar current={prayer.current} max={prayer.max} color='#336699' className='player-stats__prayer-bar' />
|
||||
<div className='player-stats__prayer-numbers'>
|
||||
{prayer.current} / {prayer.max}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Energy Bar */}
|
||||
<div className='player-stats__energy'>
|
||||
<StatBar
|
||||
current={Math.floor(energy.current / 100)}
|
||||
max={Math.floor(energy.max / 100)}
|
||||
color='#a9a9a9'
|
||||
className='player-stats__energy-bar'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
os-league-tools-master/src/components/group/SkillBox.js
Normal file
50
os-league-tools-master/src/components/group/SkillBox.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Skill } from '../../models/Skill';
|
||||
import './GroupComponents.css';
|
||||
|
||||
/**
|
||||
* SkillBox component - displays a single skill with level and XP bar
|
||||
* Matches the original group-ironmen-master design
|
||||
*/
|
||||
export default function SkillBox({ skill, onMouseEnter, onMouseLeave, onMouseMove }) {
|
||||
if (!skill || !(skill instanceof Skill)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = skill.levelProgress * 100;
|
||||
|
||||
// Calculate hue for progress bar based on percentage (0-120 degrees for red to green)
|
||||
const hue = (percentage / 100) * 120;
|
||||
const barStyle = {
|
||||
width: `${Math.min(100, percentage)}%`,
|
||||
background: `hsl(${hue}, 70%, 50%)`,
|
||||
};
|
||||
|
||||
const leftStyle = { backgroundImage: 'url(/ui/174-0.png)' };
|
||||
const rightStyle = { backgroundImage: 'url(/ui/175-0.png)' };
|
||||
|
||||
// Create tooltip content
|
||||
const tooltipContent = `Total XP: ${skill.xp.toLocaleString()}<br />XP until next level: ${skill.xpUntilNextLevel.toLocaleString()}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='skill-box'
|
||||
onMouseEnter={(e) => onMouseEnter && onMouseEnter(tooltipContent, e)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseMove={onMouseMove}
|
||||
>
|
||||
<div className='skill-box__left' style={leftStyle}>
|
||||
<img src={skill.icon} alt={skill.name} className='skill-box__icon pixelated' />
|
||||
</div>
|
||||
|
||||
<div className='skill-box__right' style={rightStyle}>
|
||||
<div className='skill-box__current-level'>{skill.currentLevel}</div>
|
||||
<div className='skill-box__baseline-level'>{skill.trueLevel}</div>
|
||||
</div>
|
||||
|
||||
<div className='skill-box__progress'>
|
||||
<div className='skill-box__progress-bar' style={barStyle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
os-league-tools-master/src/components/group/StatBar.js
Normal file
22
os-league-tools-master/src/components/group/StatBar.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import './GroupComponents.css';
|
||||
|
||||
/**
|
||||
* StatBar component - displays a stat bar with current/max values
|
||||
* Matches the original group-ironmen-master design
|
||||
* Used for HP, Prayer, Energy
|
||||
*/
|
||||
export default function StatBar({ current, max, color, className = '' }) {
|
||||
const percentage = max > 0 ? (current / max) * 100 : 0;
|
||||
|
||||
// Create gradient background for the stat bar
|
||||
const barStyle = {
|
||||
background: `linear-gradient(to right, ${color} ${percentage}%, rgba(0, 0, 0, 0.5) ${percentage}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`stat-bar ${className}`}>
|
||||
<div className='stat-bar__fill' style={barStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
os-league-tools-master/src/components/group/XpDropper.js
Normal file
111
os-league-tools-master/src/components/group/XpDropper.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './GroupComponents.css';
|
||||
|
||||
// Skill icon mapping
|
||||
const skillIconMap = {
|
||||
Attack: '/ui/197-0.png',
|
||||
Strength: '/ui/198-0.png',
|
||||
Defence: '/ui/199-0.png',
|
||||
Ranged: '/ui/200-0.png',
|
||||
Prayer: '/ui/201-0.png',
|
||||
Magic: '/ui/202-0.png',
|
||||
Hitpoints: '/ui/203-0.png',
|
||||
Agility: '/ui/204-0.png',
|
||||
Herblore: '/ui/205-0.png',
|
||||
Thieving: '/ui/206-0.png',
|
||||
Crafting: '/ui/207-0.png',
|
||||
Fletching: '/ui/208-0.png',
|
||||
Mining: '/ui/209-0.png',
|
||||
Smithing: '/ui/210-0.png',
|
||||
Fishing: '/ui/211-0.png',
|
||||
Cooking: '/ui/212-0.png',
|
||||
Firemaking: '/ui/213-0.png',
|
||||
Woodcutting: '/ui/214-0.png',
|
||||
Runecraft: '/ui/215-0.png',
|
||||
Slayer: '/ui/216-0.png',
|
||||
Farming: '/ui/217-0.png',
|
||||
Hunter: '/ui/220-0.png',
|
||||
Construction: '/ui/221-0.png',
|
||||
};
|
||||
|
||||
/**
|
||||
* XpDropper component - displays animated XP drops
|
||||
* Shows floating text that scrolls upward when XP is gained
|
||||
*/
|
||||
export default function XpDropper({ member, previousSkillsRef }) {
|
||||
const [xpDrops, setXpDrops] = useState([]);
|
||||
|
||||
// Detect XP changes and create drops
|
||||
useEffect(() => {
|
||||
if (!member?.skills) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSkills = member.skills;
|
||||
const previousSkills = previousSkillsRef.current;
|
||||
|
||||
// If no previous skills, just store current and return
|
||||
if (!previousSkills) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
previousSkillsRef.current = { ...currentSkills };
|
||||
return;
|
||||
}
|
||||
|
||||
const newDrops = [];
|
||||
|
||||
// Compare each skill's XP with previous values
|
||||
Object.keys(currentSkills).forEach(skillName => {
|
||||
if (skillName === 'Overall') {
|
||||
return; // Skip overall
|
||||
}
|
||||
|
||||
const currentXp = currentSkills[skillName] || 0;
|
||||
const previousXp = previousSkills[skillName] || 0;
|
||||
const xpGained = currentXp - previousXp;
|
||||
|
||||
if (xpGained > 0) {
|
||||
newDrops.push({
|
||||
id: `${skillName}-${Date.now()}-${Math.random()}`,
|
||||
icon: skillIconMap[skillName] || '',
|
||||
xp: xpGained,
|
||||
skillName,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update previous skills AFTER comparison (using ref so no re-render)
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
previousSkillsRef.current = { ...currentSkills };
|
||||
|
||||
// Create XP drops if any
|
||||
if (newDrops.length > 0) {
|
||||
setXpDrops(prev => [...prev, ...newDrops]);
|
||||
|
||||
// Remove drops after animation completes (4 seconds)
|
||||
setTimeout(() => {
|
||||
setXpDrops(prev => prev.filter(drop => !newDrops.some(nd => nd.id === drop.id)));
|
||||
}, 4000);
|
||||
}
|
||||
}, [member?.skills, previousSkillsRef]);
|
||||
|
||||
if (xpDrops.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='xp-dropper'>
|
||||
{xpDrops.map((drop, index) => (
|
||||
<div
|
||||
key={drop.id}
|
||||
className='xp-dropper__drop'
|
||||
style={{ top: `${index * 30}px` }}
|
||||
>
|
||||
<div className='xp-dropper__item'>
|
||||
<img src={drop.icon} alt={drop.skillName} className='xp-dropper__skill-icon' />
|
||||
<span className='xp-dropper__text'>+{drop.xp.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
os-league-tools-master/src/components/modals/CreateGroupModal.js
Normal file
207
os-league-tools-master/src/components/modals/CreateGroupModal.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from '../Modal';
|
||||
import { createGroup as createGroupAPI } from '../../client/group-ironmen-client';
|
||||
import { addGroup } from '../../store/group/groupState';
|
||||
|
||||
export default function CreateGroupModal({ isOpen, onClose }) {
|
||||
const dispatch = useDispatch();
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [memberNames, setMemberNames] = useState(['', '', '', '', '']);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
|
||||
const handleMemberNameChange = (index, value) => {
|
||||
const newNames = [...memberNames];
|
||||
newNames[index] = value;
|
||||
setMemberNames(newNames);
|
||||
};
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Validate group name
|
||||
if (!groupName.trim()) {
|
||||
setError('Group name is required');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out empty member names
|
||||
const validMemberNames = memberNames.filter(name => name.trim() !== '');
|
||||
|
||||
if (validMemberNames.length === 0) {
|
||||
setError('At least one member name is required');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Creating group:', { groupName, memberNames: validMemberNames });
|
||||
const result = await createGroupAPI(groupName, validMemberNames);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Create group result:', result);
|
||||
|
||||
if (result.success) {
|
||||
// Add group to Redux store
|
||||
dispatch(
|
||||
addGroup({
|
||||
name: groupName,
|
||||
token: result.value.token,
|
||||
setActive: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Show success message with token
|
||||
setToken(result.value.token);
|
||||
} else {
|
||||
setError(result.error || 'Failed to create group');
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error creating group:', err);
|
||||
setError(err.message || 'An unexpected error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setGroupName('');
|
||||
setMemberNames(['', '', '', '', '']);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
setIsSubmitting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title='Create New Group'>
|
||||
{token ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='bg-green-500 bg-opacity-10 border border-green-500 rounded p-4'>
|
||||
<p className='text-green-400 font-semibold mb-2'>Group Created Successfully!</p>
|
||||
<p className='text-gray-300 text-sm'>
|
||||
Your group has been created. Share this token with your team members so they can join.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='bg-gray-700 rounded p-4'>
|
||||
<div className='block text-sm font-medium text-white mb-2'>
|
||||
Group Token (Keep this safe!)
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
id='groupToken'
|
||||
value={token}
|
||||
readOnly
|
||||
aria-label='Group Token'
|
||||
className='flex-1 px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white text-sm font-mono'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(token);
|
||||
alert('Token copied to clipboard!');
|
||||
}}
|
||||
className='px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors'
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-gray-700 rounded p-4 text-sm text-gray-300'>
|
||||
<p className='font-medium mb-2'>Next steps:</p>
|
||||
<ol className='list-decimal list-inside space-y-1'>
|
||||
<li>Share this token with your group members</li>
|
||||
<li>Install the Group Ironmen RuneLite plugin</li>
|
||||
<li>Enter your group name and token in the plugin</li>
|
||||
<li>Start playing and watch your progress sync!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleClose}
|
||||
className='w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors'
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{error && (
|
||||
<div className='bg-red-500 bg-opacity-10 border border-red-500 rounded p-3'>
|
||||
<p className='text-red-400 text-sm'>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className='block text-sm font-medium text-white mb-2'>
|
||||
Group Name *
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
id='groupName'
|
||||
value={groupName}
|
||||
onChange={e => setGroupName(e.target.value)}
|
||||
placeholder='My Awesome Group'
|
||||
maxLength={50}
|
||||
aria-label='Group Name'
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500'
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='block text-sm font-medium text-white mb-2'>
|
||||
Member Names (up to 5)
|
||||
</div>
|
||||
<p className='text-xs text-gray-400 mb-3'>
|
||||
Enter the RuneScape display names of your group members. Leave blank if you don't know all members yet.
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{memberNames.map((name, index) => (
|
||||
<input
|
||||
key={index}
|
||||
type='text'
|
||||
value={name}
|
||||
onChange={e => handleMemberNameChange(index, e.target.value)}
|
||||
placeholder={`Member ${index + 1} (optional)`}
|
||||
maxLength={16}
|
||||
className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500'
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
className='flex-1 px-4 py-2 bg-gray-700 text-white rounded hover:bg-gray-600 transition-colors disabled:opacity-50'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className='flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50'
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
106
os-league-tools-master/src/models/Item.js
Normal file
106
os-league-tools-master/src/models/Item.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Item model with formatting and display utilities
|
||||
*/
|
||||
|
||||
import { formatShortQuantity, formatVeryShortQuantity } from '../utils/formatters';
|
||||
|
||||
class Item {
|
||||
constructor(id, quantity = 1) {
|
||||
this.id = id || 0;
|
||||
this.quantity = quantity;
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if item is valid (has an ID)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValid() {
|
||||
return this.id > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item image URL
|
||||
* @returns {string} Image path
|
||||
*/
|
||||
get imageUrl() {
|
||||
return Item.imageUrl(this.id, this.quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item name
|
||||
* @returns {string} Item name
|
||||
*/
|
||||
get name() {
|
||||
return Item.itemName(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short quantity string (1.5K, 2.3M)
|
||||
* @returns {string}
|
||||
*/
|
||||
get shortQuantity() {
|
||||
return formatShortQuantity(this.quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get very short quantity string (1K, 2M)
|
||||
* @returns {string}
|
||||
*/
|
||||
get veryShortQuantity() {
|
||||
return formatVeryShortQuantity(this.quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OSRS Wiki link
|
||||
* @returns {string} Wiki URL
|
||||
*/
|
||||
get wikiLink() {
|
||||
const name = this.name.replace(/ /g, '_');
|
||||
return `https://oldschool.runescape.wiki/w/${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item image URL by ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @returns {string} Image path
|
||||
*/
|
||||
static imageUrl(itemId) {
|
||||
if (!itemId || itemId <= 0) {
|
||||
return '/icons/items/blank.webp';
|
||||
}
|
||||
return `/icons/items/${itemId}.webp`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item name by ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @returns {string} Item name
|
||||
*/
|
||||
static itemName(itemId) {
|
||||
// TODO: Load from item_data.json
|
||||
return `Item ${itemId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse item data from API
|
||||
* @param {Object|Array} data - Item data
|
||||
* @returns {Item|Array<Item>}
|
||||
*/
|
||||
static parseItemData(data) {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => {
|
||||
if (item.id !== undefined) {
|
||||
return new Item(item.id, item.quantity || 1);
|
||||
}
|
||||
return new Item(0, 0);
|
||||
});
|
||||
}
|
||||
if (data && data.id !== undefined) {
|
||||
return new Item(data.id, data.quantity || 1);
|
||||
}
|
||||
return new Item(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export default Item;
|
||||
203
os-league-tools-master/src/models/Skill.js
Normal file
203
os-league-tools-master/src/models/Skill.js
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Skill model with XP calculations
|
||||
*/
|
||||
|
||||
// XP table for levels 1-126
|
||||
const XP_TABLE = [
|
||||
0, 83, 174, 276, 388, 512, 650, 801, 969, 1154, 1358, 1584, 1833, 2107, 2411,
|
||||
2746, 3115, 3523, 3973, 4470, 5018, 5624, 6291, 7028, 7842, 8740, 9730, 10824,
|
||||
12031, 13363, 14833, 16456, 18247, 20224, 22406, 24815, 27473, 30408, 33648,
|
||||
37224, 41171, 45529, 50339, 55649, 61512, 67983, 75127, 83014, 91721, 101333,
|
||||
111945, 123660, 136594, 150872, 166636, 184040, 203254, 224466, 247886, 273742,
|
||||
302288, 333804, 368599, 407015, 449428, 496254, 547953, 605032, 668051, 737627,
|
||||
814445, 899257, 992895, 1096278, 1210421, 1336443, 1475581, 1629200, 1798808,
|
||||
1986068, 2192818, 2421087, 2673114, 2951373, 3258594, 3597792, 3972294, 4385776,
|
||||
4842295, 5346332, 5902831, 6517253, 7195629, 7944614, 8771558, 9684577, 10692629,
|
||||
11805606, 13034431, 14391160, 15889109, 17542976, 19368992, 21385073, 23611006,
|
||||
26068632, 28782069, 31777943, 35085654, 38737661, 42769801, 47221641, 52136869,
|
||||
57563718, 63555443, 70170840, 77474828, 85539082, 94442737, 104273167, 115126838,
|
||||
127110260, 140341028, 154948977, 171077457, 188884740,
|
||||
];
|
||||
|
||||
// IMPORTANT: This order MUST match the plugin's SkillState.java get() method
|
||||
// which sends skills in alphabetical order
|
||||
export const SKILL_NAMES = [
|
||||
'Agility',
|
||||
'Attack',
|
||||
'Construction',
|
||||
'Cooking',
|
||||
'Crafting',
|
||||
'Defence',
|
||||
'Farming',
|
||||
'Firemaking',
|
||||
'Fishing',
|
||||
'Fletching',
|
||||
'Herblore',
|
||||
'Hitpoints',
|
||||
'Hunter',
|
||||
'Magic',
|
||||
'Mining',
|
||||
'Prayer',
|
||||
'Ranged',
|
||||
'Runecraft',
|
||||
'Slayer',
|
||||
'Smithing',
|
||||
'Strength',
|
||||
'Thieving',
|
||||
'Woodcutting',
|
||||
];
|
||||
|
||||
export class Skill {
|
||||
constructor(name, xp) {
|
||||
this.name = name;
|
||||
this.xp = xp || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate level from XP
|
||||
* @returns {number} Level (1-126)
|
||||
*/
|
||||
get level() {
|
||||
for (let i = XP_TABLE.length - 1; i >= 0; i--) {
|
||||
if (this.xp >= XP_TABLE[i]) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current level capped at 99
|
||||
* @returns {number} Level (1-99)
|
||||
*/
|
||||
get currentLevel() {
|
||||
return Math.min(this.level, 99);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get true level (can exceed 99)
|
||||
* @returns {number} Level (1-126)
|
||||
*/
|
||||
get trueLevel() {
|
||||
return this.level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress through current level (0-1)
|
||||
* @returns {number} Progress percentage
|
||||
*/
|
||||
get levelProgress() {
|
||||
if (this.level >= 126) {
|
||||
return 1;
|
||||
}
|
||||
const currentLevelXp = XP_TABLE[this.level - 1];
|
||||
const nextLevelXp = XP_TABLE[this.level];
|
||||
const xpIntoLevel = this.xp - currentLevelXp;
|
||||
const xpNeededForLevel = nextLevelXp - currentLevelXp;
|
||||
return xpIntoLevel / xpNeededForLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XP until next level
|
||||
* @returns {number} XP remaining
|
||||
*/
|
||||
get xpUntilNextLevel() {
|
||||
if (this.level >= 126) {
|
||||
return 0;
|
||||
}
|
||||
return XP_TABLE[this.level] - this.xp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill icon URL
|
||||
* @returns {string} Icon path
|
||||
*/
|
||||
get icon() {
|
||||
return Skill.getIcon(this.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill icon URL by name
|
||||
* @param {string} skillName - Name of the skill
|
||||
* @returns {string} Icon path
|
||||
*/
|
||||
static getIcon(skillName) {
|
||||
// Map skill names to UI icon filenames (matching original group-ironmen-master)
|
||||
const iconMap = {
|
||||
Attack: '/ui/197-0.png',
|
||||
Strength: '/ui/198-0.png',
|
||||
Defence: '/ui/199-0.png',
|
||||
Ranged: '/ui/200-0.png',
|
||||
Prayer: '/ui/201-0.png',
|
||||
Magic: '/ui/202-0.png',
|
||||
Hitpoints: '/ui/203-0.png',
|
||||
Agility: '/ui/204-0.png',
|
||||
Herblore: '/ui/205-0.png',
|
||||
Thieving: '/ui/206-0.png',
|
||||
Crafting: '/ui/207-0.png',
|
||||
Fletching: '/ui/208-0.png',
|
||||
Mining: '/ui/209-0.png',
|
||||
Smithing: '/ui/210-0.png',
|
||||
Fishing: '/ui/211-0.png',
|
||||
Cooking: '/ui/212-0.png',
|
||||
Firemaking: '/ui/213-0.png',
|
||||
Woodcutting: '/ui/214-0.png',
|
||||
Runecraft: '/ui/215-0.png',
|
||||
Slayer: '/ui/216-0.png',
|
||||
Farming: '/ui/217-0.png',
|
||||
Hunter: '/ui/220-0.png',
|
||||
Construction: '/ui/221-0.png',
|
||||
};
|
||||
return iconMap[skillName] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse skill data from API response
|
||||
* @param {Object} skillsData - Skills object with XP values
|
||||
* @returns {Object} Map of skill name to Skill instance
|
||||
*/
|
||||
static parseSkillData(skillsData) {
|
||||
const skills = {};
|
||||
for (const [name, xp] of Object.entries(skillsData)) {
|
||||
skills[name] = new Skill(name, xp);
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combat level from skills
|
||||
* @param {Object} skills - Skills object
|
||||
* @returns {number} Combat level
|
||||
*/
|
||||
static calculateCombatLevel(skills) {
|
||||
const attack = skills.Attack?.level || 1;
|
||||
const strength = skills.Strength?.level || 1;
|
||||
const defence = skills.Defence?.level || 1;
|
||||
const hitpoints = skills.Hitpoints?.level || 10;
|
||||
const prayer = skills.Prayer?.level || 1;
|
||||
const ranged = skills.Ranged?.level || 1;
|
||||
const magic = skills.Magic?.level || 1;
|
||||
|
||||
const base = 0.25 * (defence + hitpoints + Math.floor(prayer / 2));
|
||||
const melee = 0.325 * (attack + strength);
|
||||
const range = 0.325 * (Math.floor(ranged / 2) + ranged);
|
||||
const mage = 0.325 * (Math.floor(magic / 2) + magic);
|
||||
|
||||
return Math.floor(base + Math.max(melee, range, mage));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total level from skills
|
||||
* @param {Object} skills - Skills object
|
||||
* @returns {number} Total level
|
||||
*/
|
||||
static calculateTotalLevel(skills) {
|
||||
let total = 0;
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
if (skills[skillName]) {
|
||||
total += skills[skillName].currentLevel;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
177
os-league-tools-master/src/pages/GroupPanel.js
Normal file
177
os-league-tools-master/src/pages/GroupPanel.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchGroupData, selectGroupData } from '../store/group/groupState';
|
||||
import Spinner from '../components/common/Spinner';
|
||||
import PlayerStats from '../components/group/PlayerStats';
|
||||
import PlayerSkills from '../components/group/PlayerSkills';
|
||||
import PlayerInventory from '../components/group/PlayerInventory';
|
||||
import PlayerEquipment from '../components/group/PlayerEquipment';
|
||||
import XpDropper from '../components/group/XpDropper';
|
||||
|
||||
export default function GroupPanel({ group }) {
|
||||
const dispatch = useDispatch();
|
||||
const groupData = useSelector(state => selectGroupData(state, group.name));
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch group data on mount and set up auto-refresh
|
||||
dispatch(fetchGroupData(group.name, group.token));
|
||||
|
||||
// Auto-refresh every 2 seconds for more responsive XP drops
|
||||
const interval = setInterval(() => {
|
||||
dispatch(fetchGroupData(group.name, group.token, true)); // Force reload to bypass cache
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch, group.name, group.token]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
dispatch(fetchGroupData(group.name, group.token, true));
|
||||
};
|
||||
|
||||
if (!groupData) {
|
||||
return (
|
||||
<div className='flex justify-center items-center py-12'>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groupData.loading && !groupData.data) {
|
||||
return (
|
||||
<div className='flex justify-center items-center py-12'>
|
||||
<Spinner />
|
||||
<span className='ml-3 text-body-secondary'>Loading group data...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groupData.error) {
|
||||
return (
|
||||
<div className='text-center py-12'>
|
||||
<p className='text-error mb-4'>Error loading group data</p>
|
||||
<p className='text-body-secondary text-sm mb-4'>{groupData.error}</p>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRefresh}
|
||||
className='px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover transition-colors'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const members = groupData.data || [];
|
||||
|
||||
// Debug logging
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('GroupPanel - groupData:', groupData);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('GroupPanel - members:', members);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex justify-between items-center mb-4'>
|
||||
<h2 className='text-xl font-semibold text-body-text'>{group.name}</h2>
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRefresh}
|
||||
disabled={groupData.loading}
|
||||
className='px-3 py-1 text-sm bg-background-light text-body-text rounded hover:bg-hover transition-colors disabled:opacity-50'
|
||||
>
|
||||
{groupData.loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(group.token);
|
||||
alert('Token copied to clipboard!');
|
||||
}}
|
||||
className='px-3 py-1 text-sm bg-background-light text-body-text rounded hover:bg-hover transition-colors'
|
||||
>
|
||||
Copy Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-background-light rounded-lg p-4'>
|
||||
<h3 className='text-lg font-medium text-body-text mb-3'>
|
||||
Group Members ({members.length}/5)
|
||||
</h3>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<p className='text-body-secondary text-center py-8'>
|
||||
No member data available yet. Members will appear here once they start using the RuneLite plugin.
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{members.map(member => (
|
||||
<MemberCard key={member.name} member={member} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='mt-4 p-3 bg-background-light rounded text-sm text-body-secondary'>
|
||||
<p className='font-medium mb-1'>How to use:</p>
|
||||
<ol className='list-decimal list-inside space-y-1'>
|
||||
<li>Share your group token with your team members</li>
|
||||
<li>Install the Group Ironmen RuneLite plugin</li>
|
||||
<li>Enter your group name and token in the plugin settings</li>
|
||||
<li>Your progress will automatically sync to this tracker!</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCard({ member }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const previousSkillsRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div className='bg-gray-900 rounded-lg border border-gray-700 overflow-hidden'>
|
||||
{/* Member Header */}
|
||||
<div
|
||||
className='flex justify-between items-center p-4 cursor-pointer hover:bg-gray-800 transition-colors'
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div>
|
||||
<h4 className='text-lg font-bold text-white'>{member.name || 'Unknown Member'}</h4>
|
||||
<p className='text-sm text-gray-400'>
|
||||
{member.last_updated
|
||||
? `Last updated: ${new Date(member.last_updated).toLocaleString()}`
|
||||
: 'No data yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='text-gray-400'>
|
||||
<span className='text-2xl'>{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Member Details */}
|
||||
{expanded && (
|
||||
<div className='p-4 pt-0 space-y-4' style={{ position: 'relative' }}>
|
||||
{/* XP Dropper - positioned absolutely over the content */}
|
||||
<XpDropper member={member} previousSkillsRef={previousSkillsRef} />
|
||||
|
||||
{/* Player Stats (HP/Prayer/Energy) */}
|
||||
<PlayerStats member={member} />
|
||||
|
||||
{/* Skills Grid */}
|
||||
<PlayerSkills member={member} />
|
||||
|
||||
{/* Side-by-side layout for Inventory and Equipment, aligned left */}
|
||||
<div className='flex gap-4 items-start'>
|
||||
{/* Inventory */}
|
||||
<PlayerInventory member={member} />
|
||||
|
||||
{/* Equipment */}
|
||||
<PlayerEquipment member={member} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
os-league-tools-master/src/pages/Groups.js
Normal file
91
os-league-tools-master/src/pages/Groups.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import Card from '../components/common/Card';
|
||||
import { selectActiveGroup, selectAllGroups, updateActiveGroup } from '../store/group/groupState';
|
||||
import GroupPanel from './GroupPanel';
|
||||
import CreateGroupModal from '../components/modals/CreateGroupModal';
|
||||
|
||||
export default function Groups() {
|
||||
const dispatch = useDispatch();
|
||||
const activeGroupIndex = useSelector(state => state.group?.activeGroup ?? null);
|
||||
const activeGroup = useSelector(selectActiveGroup);
|
||||
const allGroups = useSelector(selectAllGroups) || [];
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Groups component rendered:', { activeGroupIndex, activeGroup, allGroups });
|
||||
|
||||
const handleGroupSelect = index => {
|
||||
dispatch(updateActiveGroup(index));
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<div className='p-4'>
|
||||
<div className='flex justify-between items-center mb-6'>
|
||||
<h1 className='text-2xl font-bold'>Group Ironmen Tracker</h1>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleCreateGroup}
|
||||
className='px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700'
|
||||
>
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!allGroups || !allGroups.length ? (
|
||||
<div className='text-center py-12'>
|
||||
<p className='mb-4'>You haven't joined any groups yet.</p>
|
||||
<p className='text-sm text-gray-600'>
|
||||
Create a new group or join an existing one using your group token.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 lg:grid-cols-4 gap-4'>
|
||||
{/* Group List Sidebar */}
|
||||
<div className='lg:col-span-1'>
|
||||
<h2 className='text-lg font-semibold mb-3'>Your Groups</h2>
|
||||
<div className='space-y-2'>
|
||||
{allGroups.map((group, index) => (
|
||||
<button
|
||||
key={group.name}
|
||||
type='button'
|
||||
onClick={() => handleGroupSelect(index)}
|
||||
className={`w-full text-left px-4 py-3 rounded transition-colors ${
|
||||
index === activeGroupIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className='font-medium truncate'>{group.name}</div>
|
||||
<div className='text-xs opacity-75 truncate'>
|
||||
{new Date(group.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Details Panel */}
|
||||
<div className='lg:col-span-3'>
|
||||
{activeGroup && <GroupPanel group={activeGroup} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{isCreateModalOpen && (
|
||||
<CreateGroupModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import unlocksReducer, { loadState as loadUnlocksState } from './store/unlocks/u
|
||||
import characterReducer, { loadState as loadCharacterState, selectActiveCharacter } from './store/user/character';
|
||||
import accountReducer, { loadState as loadAccountState } from './store/user/account';
|
||||
import calculatorsReducer, { loadState as loadCalculatorsState } from './store/calculators/calculators';
|
||||
import groupReducer, { loadState as loadGroupState } from './store/group/groupState';
|
||||
|
||||
const reducer = {
|
||||
filters: filterReducer,
|
||||
@@ -17,6 +18,7 @@ const reducer = {
|
||||
character: characterReducer,
|
||||
account: accountReducer,
|
||||
calculators: calculatorsReducer,
|
||||
group: groupReducer,
|
||||
};
|
||||
|
||||
const preloadedState = {
|
||||
@@ -27,6 +29,7 @@ const preloadedState = {
|
||||
character: loadCharacterState(),
|
||||
account: loadAccountState(),
|
||||
calculators: loadCalculatorsState(),
|
||||
group: loadGroupState(),
|
||||
};
|
||||
|
||||
const store = configureStore({
|
||||
@@ -46,6 +49,7 @@ store.subscribe(
|
||||
updateLocalStorage(LOCALSTORAGE_KEYS.CHARACTER, store.getState().character);
|
||||
updateLocalStorage(LOCALSTORAGE_KEYS.ACCOUNT, store.getState().account);
|
||||
updateLocalStorage(LOCALSTORAGE_KEYS.CALCULATORS, store.getState().calculators);
|
||||
updateLocalStorage(LOCALSTORAGE_KEYS.GROUP, store.getState().group);
|
||||
}, 200)
|
||||
);
|
||||
|
||||
|
||||
10
os-league-tools-master/src/store/group/constants.js
Normal file
10
os-league-tools-master/src/store/group/constants.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export const CURRENT_VERSION = 1;
|
||||
|
||||
export const GROUP_DATA_TTL = 30000; // 30 seconds in ms (shorter TTL for real-time group updates)
|
||||
|
||||
export const INITIAL_STATE = {
|
||||
version: CURRENT_VERSION,
|
||||
activeGroup: null, // Index of the active group in the groups array
|
||||
groups: [], // Array of { name, token, createdAt }
|
||||
groupDataCache: {}, // Keyed by group name, value has shape: { lastUpdated, loading, data, error }
|
||||
};
|
||||
202
os-league-tools-master/src/store/group/groupState.js
Normal file
202
os-league-tools-master/src/store/group/groupState.js
Normal file
@@ -0,0 +1,202 @@
|
||||
/* Redux toolkit middleware handles updates immutably, but eslint doesn't know that */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { getGroupData } from '../../client/group-ironmen-client';
|
||||
import { getFromLocalStorage, LOCALSTORAGE_KEYS } from '../../client/localstorage-client';
|
||||
import updateWithUserDataStorage from '../updateWithUserDataStorage';
|
||||
import { INITIAL_STATE, GROUP_DATA_TTL } from './constants';
|
||||
import { transformMembersData } from '../../utils/apiTransformers';
|
||||
|
||||
export const groupSlice = createSlice({
|
||||
name: 'group',
|
||||
initialState: INITIAL_STATE,
|
||||
reducers: {
|
||||
updateActiveGroup: (state, action) => {
|
||||
state.activeGroup = action.payload;
|
||||
},
|
||||
addGroup: (state, action) => {
|
||||
const { name, token } = action.payload;
|
||||
state.groups.push({
|
||||
name,
|
||||
token,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
// Initialize cache for this group
|
||||
state.groupDataCache[name] = {
|
||||
lastUpdated: 0,
|
||||
loading: false,
|
||||
data: null,
|
||||
error: null,
|
||||
};
|
||||
// Set as active if it's the first group or if specified
|
||||
if (action.payload.setActive || state.groups.length === 1) {
|
||||
state.activeGroup = state.groups.length - 1;
|
||||
}
|
||||
},
|
||||
deleteGroup: (state, action) => {
|
||||
const groupName = state.groups[action.payload]?.name;
|
||||
state.groups.splice(action.payload, 1);
|
||||
|
||||
// Remove from cache
|
||||
if (groupName && state.groupDataCache[groupName]) {
|
||||
delete state.groupDataCache[groupName];
|
||||
}
|
||||
|
||||
// Update active group index
|
||||
if (state.activeGroup === action.payload) {
|
||||
state.activeGroup = state.groups.length > 0 ? 0 : null;
|
||||
} else if (state.activeGroup > action.payload) {
|
||||
state.activeGroup -= 1;
|
||||
}
|
||||
},
|
||||
updateGroupData: (state, action) => {
|
||||
const { groupName, type, value } = action.payload;
|
||||
|
||||
if (!state.groupDataCache[groupName]) {
|
||||
state.groupDataCache[groupName] = {
|
||||
lastUpdated: 0,
|
||||
loading: false,
|
||||
data: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'LOADING':
|
||||
state.groupDataCache[groupName].loading = true;
|
||||
break;
|
||||
case 'SUCCESS':
|
||||
state.groupDataCache[groupName].data = value;
|
||||
state.groupDataCache[groupName].error = null;
|
||||
state.groupDataCache[groupName].lastUpdated = Date.now();
|
||||
state.groupDataCache[groupName].loading = false;
|
||||
break;
|
||||
case 'ERROR':
|
||||
state.groupDataCache[groupName].error = value;
|
||||
state.groupDataCache[groupName].lastUpdated = Date.now();
|
||||
state.groupDataCache[groupName].loading = false;
|
||||
break;
|
||||
default:
|
||||
console.warn(`updateGroupData called with unexpected action type ${type}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
load: (state, action) => {
|
||||
const fallbackState = action.payload.forceOverwrite ? INITIAL_STATE : state;
|
||||
return {
|
||||
...fallbackState,
|
||||
...action.payload.newState,
|
||||
};
|
||||
},
|
||||
reset: () => INITIAL_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
export const loadState = () => {
|
||||
const prevState = getFromLocalStorage(LOCALSTORAGE_KEYS.GROUP, INITIAL_STATE);
|
||||
// Add version migration logic here if needed in the future
|
||||
return prevState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selector to get the active group object
|
||||
*/
|
||||
export function selectActiveGroup(state) {
|
||||
if (!state.group || state.group.activeGroup === null || !state.group.groups?.length) {
|
||||
return null;
|
||||
}
|
||||
return state.group.groups[state.group.activeGroup] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector to get all groups
|
||||
*/
|
||||
export function selectAllGroups(state) {
|
||||
return state.group?.groups || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector to get group data for a specific group
|
||||
*/
|
||||
export function selectGroupData(state, groupName) {
|
||||
if (!state.group?.groupDataCache) {
|
||||
return null;
|
||||
}
|
||||
return state.group.groupDataCache[groupName] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch group data from the backend
|
||||
*/
|
||||
export function fetchGroupData(groupName, token, forceReload = false) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState().group;
|
||||
const cache = state.groupDataCache[groupName];
|
||||
|
||||
const isFreshData = cache && (Date.now() - cache.lastUpdated < GROUP_DATA_TTL);
|
||||
if (!forceReload && isFreshData) {
|
||||
// Do nothing, data is already up-to-date
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
dispatch(groupSlice.actions.updateGroupData({ groupName, type: 'LOADING' }));
|
||||
|
||||
return getGroupData(groupName, token)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
// Transform API response data into frontend format
|
||||
const transformedData = transformMembersData(result.value);
|
||||
dispatch(groupSlice.actions.updateGroupData({
|
||||
groupName,
|
||||
type: 'SUCCESS',
|
||||
value: transformedData,
|
||||
}));
|
||||
} else {
|
||||
dispatch(groupSlice.actions.updateGroupData({
|
||||
groupName,
|
||||
type: 'ERROR',
|
||||
value: result.error || 'Failed to fetch group data',
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(groupSlice.actions.updateGroupData({
|
||||
groupName,
|
||||
type: 'ERROR',
|
||||
value: error.message || 'Failed to fetch group data',
|
||||
}));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
updateActiveGroup: innerUpdateActiveGroup,
|
||||
addGroup: innerAddGroup,
|
||||
deleteGroup: innerDeleteGroup,
|
||||
load: innerLoad,
|
||||
reset: innerReset,
|
||||
} = groupSlice.actions;
|
||||
|
||||
export function updateActiveGroup(props) {
|
||||
return updateWithUserDataStorage(innerUpdateActiveGroup, props, LOCALSTORAGE_KEYS.GROUP, 'group');
|
||||
}
|
||||
|
||||
export function addGroup(props) {
|
||||
return updateWithUserDataStorage(innerAddGroup, props, LOCALSTORAGE_KEYS.GROUP, 'group');
|
||||
}
|
||||
|
||||
export function deleteGroup(props) {
|
||||
return updateWithUserDataStorage(innerDeleteGroup, props, LOCALSTORAGE_KEYS.GROUP, 'group');
|
||||
}
|
||||
|
||||
export function load(props) {
|
||||
return updateWithUserDataStorage(innerLoad, props, LOCALSTORAGE_KEYS.GROUP, 'group');
|
||||
}
|
||||
|
||||
export function reset(props) {
|
||||
return updateWithUserDataStorage(innerReset, props, LOCALSTORAGE_KEYS.GROUP, 'group');
|
||||
}
|
||||
|
||||
export const { updateGroupData } = groupSlice.actions;
|
||||
|
||||
export default groupSlice.reducer;
|
||||
@@ -2122,15 +2122,37 @@ select[multiple]:focus option:checked {
|
||||
box-shadow: inset 0px -7px 5px -5px var(--tw-shadow-color), 3px -3px 3px -3px var(--tw-shadow-color),
|
||||
-3px -3px 3px -3px var(--tw-shadow-color);
|
||||
}
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.inset-0 {
|
||||
inset: 0px;
|
||||
}
|
||||
.inset-y-0 {
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
}
|
||||
.left-0 {
|
||||
left: 0px;
|
||||
}
|
||||
.left-full {
|
||||
left: 100%;
|
||||
}
|
||||
.right-0 {
|
||||
right: 0px;
|
||||
}
|
||||
@@ -2206,6 +2228,9 @@ select[multiple]:focus option:checked {
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.mb-auto {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
@@ -2290,9 +2315,15 @@ select[multiple]:focus option:checked {
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
.h-12 {
|
||||
height: 3rem;
|
||||
}
|
||||
.h-2 {
|
||||
height: 0.5rem;
|
||||
}
|
||||
.h-24 {
|
||||
height: 6rem;
|
||||
}
|
||||
@@ -2314,6 +2345,9 @@ select[multiple]:focus option:checked {
|
||||
.h-5 {
|
||||
height: 1.25rem;
|
||||
}
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
@@ -2362,12 +2396,18 @@ select[multiple]:focus option:checked {
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.w-10 {
|
||||
width: 2.5rem;
|
||||
}
|
||||
.w-11\/12 {
|
||||
width: 91.666667%;
|
||||
}
|
||||
.w-12 {
|
||||
width: 3rem;
|
||||
}
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
.w-24 {
|
||||
width: 6rem;
|
||||
}
|
||||
@@ -2419,12 +2459,18 @@ select[multiple]:focus option:checked {
|
||||
.w-px {
|
||||
width: 1px;
|
||||
}
|
||||
.min-w-0 {
|
||||
min-width: 0px;
|
||||
}
|
||||
.min-w-\[100px\] {
|
||||
min-width: 100px;
|
||||
}
|
||||
.min-w-\[26rem\] {
|
||||
min-width: 26rem;
|
||||
}
|
||||
.min-w-max {
|
||||
min-width: max-content;
|
||||
}
|
||||
.max-w-5xl {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@@ -2449,6 +2495,15 @@ select[multiple]:focus option:checked {
|
||||
.max-w-\[90\%\] {
|
||||
max-width: 90%;
|
||||
}
|
||||
.max-w-fit {
|
||||
max-width: fit-content;
|
||||
}
|
||||
.max-w-xs {
|
||||
max-width: 20rem;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
.shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
@@ -2594,11 +2649,26 @@ select[multiple]:focus option:checked {
|
||||
.gap-x-1 {
|
||||
column-gap: 0.25rem;
|
||||
}
|
||||
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -2608,6 +2678,11 @@ select[multiple]:focus option:checked {
|
||||
.overflow-y-scroll {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -2670,6 +2745,9 @@ select[multiple]:focus option:checked {
|
||||
.border-b-2 {
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
.border-l {
|
||||
border-left-width: 1px;
|
||||
}
|
||||
.border-l-2 {
|
||||
border-left-width: 2px;
|
||||
}
|
||||
@@ -2695,13 +2773,79 @@ select[multiple]:focus option:checked {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(0 0 0 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-600 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(75 85 99 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-700 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(55 65 81 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-green-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-red-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.bg-blue-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-blue-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-800 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-900 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-950 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(3 7 18 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-green-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-green-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-red-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-red-900\/90 {
|
||||
background-color: rgb(127 29 29 / 0.9);
|
||||
}
|
||||
.bg-yellow-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-yellow-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(202 138 4 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-opacity-10 {
|
||||
--tw-bg-opacity: 0.1;
|
||||
}
|
||||
.object-contain {
|
||||
object-fit: contain;
|
||||
}
|
||||
.object-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -2717,6 +2861,9 @@ select[multiple]:focus option:checked {
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
@@ -2750,17 +2897,32 @@ select[multiple]:focus option:checked {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.py-12 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.py-3 {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
.py-5 {
|
||||
padding-top: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
.py-8 {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
.pb-1 {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.pb-3 {
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
@@ -2776,6 +2938,12 @@ select[multiple]:focus option:checked {
|
||||
.pr-2 {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.pt-0 {
|
||||
padding-top: 0px;
|
||||
}
|
||||
.pt-1 {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
.pt-2 {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
@@ -2841,6 +3009,9 @@ select[multiple]:focus option:checked {
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -2871,6 +3042,26 @@ select[multiple]:focus option:checked {
|
||||
.tracking-widest {
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.text-gray-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-green-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-orange-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(251 146 60 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-red-100 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(254 226 226 / var(--tw-text-opacity, 1));
|
||||
@@ -2879,9 +3070,28 @@ select[multiple]:focus option:checked {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(254 202 202 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-white {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-yellow-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(250 204 21 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.placeholder-gray-400::placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1));
|
||||
}
|
||||
.opacity-20 {
|
||||
opacity: 0.2;
|
||||
}
|
||||
.opacity-25 {
|
||||
opacity: 0.25;
|
||||
}
|
||||
@@ -2908,6 +3118,10 @@ select[multiple]:focus option:checked {
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
.drop-shadow-\[0_1px_1px_rgba\(0\2c 0\2c 0\2c 0\.8\)\] {
|
||||
--tw-drop-shadow: drop-shadow(0 1px 1px rgba(0,0,0,0.8));
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
.filter {
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
@@ -2916,6 +3130,19 @@ select[multiple]:focus option:checked {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.duration-300 {
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
.odd\:bg-primary:nth-child(odd) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
@@ -2940,6 +3167,14 @@ select[multiple]:focus option:checked {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(53 53 59 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-hover:hover:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-hover:hover:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(39 39 42 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.xl\:bg-primary {
|
||||
--tw-bg-opacity: 1;
|
||||
@@ -2984,6 +3219,22 @@ select[multiple]:focus option:checked {
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
.hover\:border-gray-500:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(107 114 128 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.hover\:bg-blue-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-800:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:text-white:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
@@ -2991,6 +3242,17 @@ select[multiple]:focus option:checked {
|
||||
.hover\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.focus\:border-blue-500:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.focus\:outline-none:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.disabled\:opacity-50:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.sm\:flex {
|
||||
display: flex;
|
||||
@@ -3063,6 +3325,12 @@ select[multiple]:focus option:checked {
|
||||
.lg\:order-3 {
|
||||
order: 3;
|
||||
}
|
||||
.lg\:col-span-1 {
|
||||
grid-column: span 1 / span 1;
|
||||
}
|
||||
.lg\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
.lg\:h-full {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -3087,9 +3355,15 @@ select[multiple]:focus option:checked {
|
||||
.lg\:basis-1\/4 {
|
||||
flex-basis: 25%;
|
||||
}
|
||||
.lg\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.lg\:flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
147
os-league-tools-master/src/utils/apiTransformers.js
Normal file
147
os-league-tools-master/src/utils/apiTransformers.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Transformers to convert Spring backend API responses into frontend data format
|
||||
*/
|
||||
|
||||
import { SKILL_NAMES } from '../models/Skill';
|
||||
|
||||
/**
|
||||
* Transform skills array from API (24 XP values) into object
|
||||
* @param {Array<number>} skillsArray - Array of 24 XP values
|
||||
* @returns {Object} Skills object with skill names as keys
|
||||
*/
|
||||
export function transformSkills(skillsArray) {
|
||||
if (!skillsArray || !Array.isArray(skillsArray)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const skills = {};
|
||||
SKILL_NAMES.forEach((skillName, index) => {
|
||||
if (index < skillsArray.length) {
|
||||
skills[skillName] = skillsArray[index];
|
||||
}
|
||||
});
|
||||
|
||||
// Add Overall (total XP)
|
||||
skills.Overall = skillsArray.reduce((sum, xp) => sum + xp, 0);
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform stats array from API into structured object
|
||||
* API format: [hp_current, hp_max, prayer_current, prayer_max, energy_current, energy_max, world]
|
||||
* @param {Array<number>} statsArray - Array of 7 integers
|
||||
* @returns {Object} Stats object
|
||||
*/
|
||||
export function transformStats(statsArray) {
|
||||
if (!statsArray || !Array.isArray(statsArray) || statsArray.length < 7) {
|
||||
return {
|
||||
hitpoints: { current: 10, max: 10 },
|
||||
prayer: { current: 1, max: 1 },
|
||||
energy: { current: 10000, max: 10000 },
|
||||
world: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hitpoints: {
|
||||
current: statsArray[0],
|
||||
max: statsArray[1],
|
||||
},
|
||||
prayer: {
|
||||
current: statsArray[2],
|
||||
max: statsArray[3],
|
||||
},
|
||||
energy: {
|
||||
current: statsArray[4],
|
||||
max: statsArray[5],
|
||||
},
|
||||
world: statsArray[6] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform inventory/equipment array from API into item objects
|
||||
* API format: flat array alternating [id1, quantity1, id2, quantity2, ...]
|
||||
* @param {Array<number>} itemsArray - Flat array of item data
|
||||
* @returns {Array<Object>} Array of {id, quantity} objects
|
||||
*/
|
||||
export function transformItems(itemsArray) {
|
||||
if (!itemsArray || !Array.isArray(itemsArray)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = [];
|
||||
for (let i = 0; i < itemsArray.length; i += 2) {
|
||||
items.push({
|
||||
id: itemsArray[i] || 0,
|
||||
quantity: itemsArray[i + 1] || 0,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform coordinates array from API
|
||||
* API format: [x, y, plane]
|
||||
* @param {Array<number>} coordsArray - Array of 3 integers
|
||||
* @returns {Object|null} Coordinates object or null
|
||||
*/
|
||||
export function transformCoordinates(coordsArray) {
|
||||
if (!coordsArray || !Array.isArray(coordsArray) || coordsArray.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: coordsArray[0],
|
||||
y: coordsArray[1],
|
||||
plane: coordsArray[2],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform full member data from API response
|
||||
* @param {Object} memberData - Raw member data from API
|
||||
* @returns {Object} Transformed member data
|
||||
*/
|
||||
export function transformMemberData(memberData) {
|
||||
if (!memberData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: memberData.name,
|
||||
skills: transformSkills(memberData.skills),
|
||||
stats: transformStats(memberData.stats),
|
||||
inventory: transformItems(memberData.inventory),
|
||||
equipment: transformItems(memberData.equipment),
|
||||
bank: transformItems(memberData.bank),
|
||||
runePouch: transformItems(memberData.rune_pouch),
|
||||
seedVault: transformItems(memberData.seed_vault),
|
||||
coordinates: transformCoordinates(memberData.coordinates),
|
||||
interacting: memberData.interacting,
|
||||
last_updated: memberData.last_updated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform array of members from API response
|
||||
* @param {Array<Object>} membersArray - Array of member data from API
|
||||
* @returns {Array<Object>} Array of transformed member data
|
||||
*/
|
||||
export function transformMembersData(membersArray) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('transformMembersData - input:', membersArray);
|
||||
|
||||
if (!Array.isArray(membersArray)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('transformMembersData - not an array!');
|
||||
return [];
|
||||
}
|
||||
|
||||
const transformed = membersArray.map(transformMemberData).filter(Boolean);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('transformMembersData - output:', transformed);
|
||||
|
||||
return transformed;
|
||||
}
|
||||
82
os-league-tools-master/src/utils/formatters.js
Normal file
82
os-league-tools-master/src/utils/formatters.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Formatting utilities for numbers, quantities, and time
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format large quantities into short strings (1.5K, 2.3M, 1B)
|
||||
* @param {number} quantity - The quantity to format
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
export function formatShortQuantity(quantity) {
|
||||
if (quantity < 1000) {
|
||||
return quantity.toString();
|
||||
}
|
||||
if (quantity < 1000000) {
|
||||
const k = Math.floor(quantity / 100) / 10;
|
||||
return `${k}K`;
|
||||
}
|
||||
if (quantity < 1000000000) {
|
||||
const m = Math.floor(quantity / 100000) / 10;
|
||||
return `${m}M`;
|
||||
}
|
||||
const b = Math.floor(quantity / 100000000) / 10;
|
||||
return `${b}B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format very large quantities into very short strings (1K, 2M, 1B)
|
||||
* @param {number} quantity - The quantity to format
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
export function formatVeryShortQuantity(quantity) {
|
||||
if (quantity < 1000) {
|
||||
return quantity.toString();
|
||||
}
|
||||
if (quantity < 1000000) {
|
||||
return `${Math.floor(quantity / 1000)}K`;
|
||||
}
|
||||
if (quantity < 1000000000) {
|
||||
return `${Math.floor(quantity / 1000000)}M`;
|
||||
}
|
||||
return `${Math.floor(quantity / 1000000000)}B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time since last update
|
||||
* @param {Date|string|number} lastUpdated - Last update timestamp
|
||||
* @returns {string} Formatted string like "2m ago" or "1h ago"
|
||||
*/
|
||||
export function timeSinceLastUpdate(lastUpdated) {
|
||||
const date = lastUpdated instanceof Date ? lastUpdated : new Date(lastUpdated);
|
||||
const now = new Date();
|
||||
const secondsAgo = Math.floor((now - date) / 1000);
|
||||
|
||||
if (secondsAgo < 60) {
|
||||
return `${secondsAgo}s ago`;
|
||||
}
|
||||
if (secondsAgo < 3600) {
|
||||
return `${Math.floor(secondsAgo / 60)}m ago`;
|
||||
}
|
||||
if (secondsAgo < 86400) {
|
||||
return `${Math.floor(secondsAgo / 3600)}h ago`;
|
||||
}
|
||||
return `${Math.floor(secondsAgo / 86400)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove articles from string
|
||||
* @param {string} str - String to process
|
||||
* @returns {string} String with articles removed
|
||||
*/
|
||||
export function removeArticles(str) {
|
||||
return str.replace(/^(a|an|the)\s+/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
* @param {number} num - Number to format
|
||||
* @returns {string} Formatted number
|
||||
*/
|
||||
export function formatNumber(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
Reference in New Issue
Block a user