Imagery imported for icons and items. Live updates working.

Hover tooltip working. Features.md added for better tracking.
This commit is contained in:
2025-10-28 08:41:04 +08:00
parent 4ea30cc12e
commit ea8484fca7
16068 changed files with 3097 additions and 6 deletions

View File

@@ -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 />} />

View 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 };
}
}

View File

@@ -7,6 +7,7 @@ export const LOCALSTORAGE_KEYS = {
CHARACTER: 'character',
CALCULATORS: 'calculators',
ACCOUNT: 'account',
GROUP: 'group',
};
export const SESSIONSTORAGE_KEYS = {};

View File

@@ -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} />

View 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 }} />
);
}

View 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);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -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)
);

View 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 }
};

View 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;

View File

@@ -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;
}

View 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;
}

View 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, ',');
}